From cd976438d37dc0b5f2f55c7cc7fc9a25bc9ddc31 Mon Sep 17 00:00:00 2001 From: Pierre Verkest Date: Mon, 18 Dec 2023 17:54:31 +0100 Subject: [PATCH] [ADD] edi_upflow: EDI mechanism to comunicate with upflow.io --------- Co-authored-by: Matthias BARKAT Co-authored-by: Alexandre Galdeano --- edi_upflow/README.rst | 131 + edi_upflow/__init__.py | 3 + edi_upflow/__manifest__.py | 36 + edi_upflow/components/__init__.py | 29 + .../base_upflow_edi_output_check.py | 87 + .../base_upflow_edi_output_generate.py | 44 + ...i_output_check_upflow_post_credit_notes.py | 14 + ...tput_check_upflow_post_credit_notes_pdf.py | 14 + .../edi_output_check_upflow_post_customers.py | 24 + .../edi_output_check_upflow_post_invoice.py | 14 + ...di_output_check_upflow_post_invoice_pdf.py | 14 + .../edi_output_check_upflow_post_payments.py | 14 + .../edi_output_check_upflow_post_reconcile.py | 16 + .../edi_output_check_upflow_post_refunds.py | 14 + .../edi_output_check_upflow_put_contacts.py | 14 + ...utput_generate_upflow_post_credit_notes.py | 18 + ...t_generate_upflow_post_credit_notes_pdf.py | 16 + ...i_output_generate_upflow_post_customers.py | 24 + ...edi_output_generate_upflow_post_invoice.py | 18 + ...output_generate_upflow_post_invoice_pdf.py | 16 + ...di_output_generate_upflow_post_payments.py | 18 + ...i_output_generate_upflow_post_reconcile.py | 28 + ...edi_output_generate_upflow_post_refunds.py | 18 + ...edi_output_generate_upflow_put_contacts.py | 17 + edi_upflow/components/edi_webservice_send.py | 29 + .../components/event_listener_account_move.py | 20 + ...vent_listener_account_partial_reconcile.py | 176 ++ edi_upflow/components/event_listener_base.py | 81 + .../event_listener_exchange_record.py | 32 + .../components/event_listener_res_partner.py | 94 + edi_upflow/components/request_adapter.py | 15 + edi_upflow/data/cron.xml | 12 + edi_upflow/data/edi.xml | 379 +++ edi_upflow/i18n/edi_upflow.pot | 404 +++ edi_upflow/i18n/fr.po | 421 +++ .../migrations/14.0.2.0.0/pre-migrate.py | 16 + edi_upflow/models/__init__.py | 7 + edi_upflow/models/account_move.py | 86 + .../models/account_partial_reconcile.py | 18 + edi_upflow/models/edi_exchange_record.py | 20 + edi_upflow/models/res_company.py | 14 + edi_upflow/models/res_config_settings.py | 12 + edi_upflow/models/res_partner.py | 81 + edi_upflow/models/webservice_backend.py | 29 + edi_upflow/readme/CONFIGURATION.rst | 14 + edi_upflow/readme/CONTRIBUTORS.rst | 5 + edi_upflow/readme/DESCRIPTION.rst | 39 + edi_upflow/readme/ROADMAP.rst | 0 edi_upflow/static/description/index.html | 462 +++ edi_upflow/tests/__init__.py | 3 + edi_upflow/tests/common.py | 61 + edi_upflow/tests/test_edi_upflow.py | 2513 +++++++++++++++++ edi_upflow/tests/test_edi_upflow_error.py | 43 + .../tests/test_multi_company_backend.py | 74 + edi_upflow/views/account_full_reconcile.xml | 30 + .../views/account_partial_reconcile.xml | 96 + edi_upflow/views/edi_exchange_record.xml | 31 + edi_upflow/views/res_config_settings.xml | 28 + edi_upflow/views/res_partner.xml | 34 + edi_upflow/views/webservice_backend.xml | 31 + requirements.txt | 2 + setup/edi_upflow/odoo/addons/edi_upflow | 1 + setup/edi_upflow/setup.py | 6 + test-requirements.txt | 4 + 64 files changed, 6064 insertions(+) create mode 100644 edi_upflow/README.rst create mode 100644 edi_upflow/__init__.py create mode 100644 edi_upflow/__manifest__.py create mode 100644 edi_upflow/components/__init__.py create mode 100644 edi_upflow/components/base_upflow_edi_output_check.py create mode 100644 edi_upflow/components/base_upflow_edi_output_generate.py create mode 100644 edi_upflow/components/edi_output_check_upflow_post_credit_notes.py create mode 100644 edi_upflow/components/edi_output_check_upflow_post_credit_notes_pdf.py create mode 100644 edi_upflow/components/edi_output_check_upflow_post_customers.py create mode 100644 edi_upflow/components/edi_output_check_upflow_post_invoice.py create mode 100644 edi_upflow/components/edi_output_check_upflow_post_invoice_pdf.py create mode 100644 edi_upflow/components/edi_output_check_upflow_post_payments.py create mode 100644 edi_upflow/components/edi_output_check_upflow_post_reconcile.py create mode 100644 edi_upflow/components/edi_output_check_upflow_post_refunds.py create mode 100644 edi_upflow/components/edi_output_check_upflow_put_contacts.py create mode 100644 edi_upflow/components/edi_output_generate_upflow_post_credit_notes.py create mode 100644 edi_upflow/components/edi_output_generate_upflow_post_credit_notes_pdf.py create mode 100644 edi_upflow/components/edi_output_generate_upflow_post_customers.py create mode 100644 edi_upflow/components/edi_output_generate_upflow_post_invoice.py create mode 100644 edi_upflow/components/edi_output_generate_upflow_post_invoice_pdf.py create mode 100644 edi_upflow/components/edi_output_generate_upflow_post_payments.py create mode 100644 edi_upflow/components/edi_output_generate_upflow_post_reconcile.py create mode 100644 edi_upflow/components/edi_output_generate_upflow_post_refunds.py create mode 100644 edi_upflow/components/edi_output_generate_upflow_put_contacts.py create mode 100644 edi_upflow/components/edi_webservice_send.py create mode 100644 edi_upflow/components/event_listener_account_move.py create mode 100644 edi_upflow/components/event_listener_account_partial_reconcile.py create mode 100644 edi_upflow/components/event_listener_base.py create mode 100644 edi_upflow/components/event_listener_exchange_record.py create mode 100644 edi_upflow/components/event_listener_res_partner.py create mode 100644 edi_upflow/components/request_adapter.py create mode 100644 edi_upflow/data/cron.xml create mode 100644 edi_upflow/data/edi.xml create mode 100644 edi_upflow/i18n/edi_upflow.pot create mode 100644 edi_upflow/i18n/fr.po create mode 100644 edi_upflow/migrations/14.0.2.0.0/pre-migrate.py create mode 100644 edi_upflow/models/__init__.py create mode 100644 edi_upflow/models/account_move.py create mode 100644 edi_upflow/models/account_partial_reconcile.py create mode 100644 edi_upflow/models/edi_exchange_record.py create mode 100644 edi_upflow/models/res_company.py create mode 100644 edi_upflow/models/res_config_settings.py create mode 100644 edi_upflow/models/res_partner.py create mode 100644 edi_upflow/models/webservice_backend.py create mode 100644 edi_upflow/readme/CONFIGURATION.rst create mode 100644 edi_upflow/readme/CONTRIBUTORS.rst create mode 100644 edi_upflow/readme/DESCRIPTION.rst create mode 100644 edi_upflow/readme/ROADMAP.rst create mode 100644 edi_upflow/static/description/index.html create mode 100644 edi_upflow/tests/__init__.py create mode 100644 edi_upflow/tests/common.py create mode 100644 edi_upflow/tests/test_edi_upflow.py create mode 100644 edi_upflow/tests/test_edi_upflow_error.py create mode 100644 edi_upflow/tests/test_multi_company_backend.py create mode 100644 edi_upflow/views/account_full_reconcile.xml create mode 100644 edi_upflow/views/account_partial_reconcile.xml create mode 100644 edi_upflow/views/edi_exchange_record.xml create mode 100644 edi_upflow/views/res_config_settings.xml create mode 100644 edi_upflow/views/res_partner.xml create mode 100644 edi_upflow/views/webservice_backend.xml create mode 100644 requirements.txt create mode 120000 setup/edi_upflow/odoo/addons/edi_upflow create mode 100644 setup/edi_upflow/setup.py create mode 100644 test-requirements.txt diff --git a/edi_upflow/README.rst b/edi_upflow/README.rst new file mode 100644 index 000000000..4aa8ca6e5 --- /dev/null +++ b/edi_upflow/README.rst @@ -0,0 +1,131 @@ +========== +EDI UPFLOW +========== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:00bc337e6cc52b983cc045c3ce3d00bac45a579a40f1c927696738ed7433fa59 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fcredit--control-lightgray.png?logo=github + :target: https://github.com/OCA/credit-control/tree/14.0/edi_upflow + :alt: OCA/credit-control +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/credit-control-14-0/credit-control-14-0-edi_upflow + :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/credit-control&target_branch=14.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + + +Based on OCA EDI Frameworks this modules aims to integrate +Odoo and Upflow.io. + +# Trigger + +Here is the list of event that generate exchange that push data to upflow: + +* When account entry of the following type are posted + out invoice / out refund / invoice payment or refund payment, then + the customer will be synchronised if no ufpflow id present in the database. + +* When full reconcile line is created it will create missing account entry if any + (backend statement reconcile can works without account payment in odoo) and send reconcile + info to upflow. + +* any change on synchronized information to a customer or a contact will update all customers + informations to upflow. + + +# Multi-company + +A customer and sales accounting entries are linked to only one backend. + +Backend are linked to a company, but once a customer has been synchronised +for a given backend (first sale accounting entry), next accounting entries +will be linked to the same backend (upflow organisation) what ever the current +backend set on the current company. + +# Asynchrone tasks + +Data are send asynchronously, according your configuration tasks can take few minutes +to be handle and send to upflow. + +On each relevent form views you will see an EDI smart button +that let you check the state of the reletated exchange synchronizations. + +This module is based on EDI Frameworks maintain by OCA which depends on the `queue_job` +module you should also monitor queue job tasks. + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +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 +~~~~~~~ + +* Pierre Verkest + +Contributors +~~~~~~~~~~~~ + +* `Foodles `_ + + * Pierre Verkest + * Alexandre Galdeano + * Matthias BARKAT + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-petrus-v| image:: https://github.com/petrus-v.png?size=40px + :target: https://github.com/petrus-v + :alt: petrus-v + +Current `maintainer `__: + +|maintainer-petrus-v| + +This module is part of the `OCA/credit-control `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/edi_upflow/__init__.py b/edi_upflow/__init__.py new file mode 100644 index 000000000..1c676dd2c --- /dev/null +++ b/edi_upflow/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import components, models diff --git a/edi_upflow/__manifest__.py b/edi_upflow/__manifest__.py new file mode 100644 index 000000000..b17748766 --- /dev/null +++ b/edi_upflow/__manifest__.py @@ -0,0 +1,36 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +{ + "name": "EDI UPFLOW", + "summary": "Odoo Upflow.io connector", + "version": "14.0.2.0.1", + "development_status": "Alpha", + "category": "EDI", + "website": "https://github.com/OCA/credit-control", + "author": "Pierre Verkest, Odoo Community Association (OCA)", + "maintainers": ["petrus-v"], + "license": "AGPL-3", + "application": True, + "depends": [ + "account", + "base_upflow", + "edi_oca", + "webservice", + "edi_webservice_oca", + "edi_account_oca", + ], + "data": [ + "data/cron.xml", + "data/edi.xml", + "views/account_full_reconcile.xml", + "views/account_partial_reconcile.xml", + "views/edi_exchange_record.xml", + "views/res_config_settings.xml", + "views/res_partner.xml", + "views/webservice_backend.xml", + ], + "external_dependencies": {"python": ["responses"]}, + "demo": [], + "installable": True, +} diff --git a/edi_upflow/components/__init__.py b/edi_upflow/components/__init__.py new file mode 100644 index 000000000..cf1618ae0 --- /dev/null +++ b/edi_upflow/components/__init__.py @@ -0,0 +1,29 @@ +from . import base_upflow_edi_output_generate +from . import base_upflow_edi_output_check +from . import event_listener_base +from . import ( + edi_output_check_upflow_post_credit_notes, + edi_output_check_upflow_post_credit_notes_pdf, + edi_output_check_upflow_post_customers, + edi_output_check_upflow_post_invoice, + edi_output_check_upflow_post_invoice_pdf, + edi_output_check_upflow_post_payments, + edi_output_check_upflow_post_reconcile, + edi_output_check_upflow_post_refunds, + edi_output_check_upflow_put_contacts, + edi_output_generate_upflow_post_credit_notes, + edi_output_generate_upflow_post_credit_notes_pdf, + edi_output_generate_upflow_post_customers, + edi_output_generate_upflow_post_invoice, + edi_output_generate_upflow_post_invoice_pdf, + edi_output_generate_upflow_post_payments, + edi_output_generate_upflow_post_reconcile, + edi_output_generate_upflow_post_refunds, + edi_output_generate_upflow_put_contacts, + edi_webservice_send, + event_listener_account_move, + event_listener_account_partial_reconcile, + event_listener_exchange_record, + event_listener_res_partner, + request_adapter, +) diff --git a/edi_upflow/components/base_upflow_edi_output_check.py b/edi_upflow/components/base_upflow_edi_output_check.py new file mode 100644 index 000000000..1dc516086 --- /dev/null +++ b/edi_upflow/components/base_upflow_edi_output_check.py @@ -0,0 +1,87 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import json + +from odoo import _ +from odoo.exceptions import ValidationError + +from odoo.addons.component.core import Component +from odoo.addons.edi_oca.models.edi_backend import _get_exception_msg + + +class BaseUpflowEDIOutputCheck(Component): + _name = "base.upflow.edi.output.check" + _inherit = "edi.component.check.mixin" + _usage = "output.check" + _backend_type = "upflow" + + _action = "check" + + def check(self): + try: + self._check_and_process() + except Exception as ex: + state = "output_sent_and_error" + self.exchange_record.exchange_error = ( + f"{ex.__class__.__name__}: {_get_exception_msg()}" + ) + else: + state = "output_sent_and_processed" + finally: + self.exchange_record.edi_exchange_state = state + + def _check_ws_response_status_code(self): + if ( + self.exchange_record.ws_response_status_code < 200 + or self.exchange_record.ws_response_status_code >= 300 + ): + raise ValidationError( + _( + "Not a valid HTTP error (expected 2xx, received %s) " + "in order to processed the payload (%s)" + ) + % ( + self.exchange_record.ws_response_status_code, + self._get_upflow_response(), + ) + ) + + def _get_upflow_response(self): + return self.exchange_record._get_file_content(field_name="ws_response_content") + + def _parse_upflow_response(self): + try: + data = json.loads(self._get_upflow_response()) + except Exception: + data = {} + return data + + def _get_response_upflow_uuid(self): + return self._parse_upflow_response()["id"] + + def _get_response_upflow_direct_url(self): + return self._parse_upflow_response().get("directUrl", False) + + def _set_record_upflow_uuid(self): + self.exchange_record.record.upflow_uuid = self._get_response_upflow_uuid() + + def _set_record_upflow_direct_url(self): + self.exchange_record.record.upflow_direct_url = ( + self._get_response_upflow_direct_url() + ) + + def _upflow_check_and_process(self): + self._check_ws_response_status_code() + self._set_record_upflow_uuid() + self._set_record_upflow_direct_url() + + def _check_and_process(self): + raise NotImplementedError( + _( + "You should implement _check_and_process method " + "to process upflow.io REST response " + "on this exchange type (code: %s)" + ) + % (self.exchange_record.type_id.code) + ) diff --git a/edi_upflow/components/base_upflow_edi_output_generate.py b/edi_upflow/components/base_upflow_edi_output_generate.py new file mode 100644 index 000000000..c4eb2d95a --- /dev/null +++ b/edi_upflow/components/base_upflow_edi_output_generate.py @@ -0,0 +1,44 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import _ + +from odoo.addons.component.core import Component +from odoo.addons.queue_job.exception import RetryableJobError + + +class BaseUpflowEDIOutputGenerate(Component): + _name = "base.upflow.edi.output.generate" + _inherit = "edi.component.output.mixin" + _usage = "output.generate" + _backend_type = "upflow" + + _action = "generate" + + def generate(self): + raise NotImplementedError( + _( + "You should implement generate method " + "to create the payload or fix the exchange type. " + "(Received exchange type code: %s)" + ) + % self.exchange_record.type_id.code + ) + + def _wait_related_exchange_to_be_sent_and_processed(self): + # while moving this to edi_oca + # https://github.com/OCA/edi/issues/782 + # consider to gives a way to make list of states configurable + # I belives in some case we wan't to generate the event event + # depends one is in error but manually fix it (an other way) + # in the mean time is to remove the dependence to force the event + if not all( + self.exchange_record.dependent_exchange_ids.mapped( + lambda rel_exch: rel_exch.edi_exchange_state + == "output_sent_and_processed" + ) + ): + raise RetryableJobError( + "Waiting related exchanges to be done before generate...", + ) diff --git a/edi_upflow/components/edi_output_check_upflow_post_credit_notes.py b/edi_upflow/components/edi_output_check_upflow_post_credit_notes.py new file mode 100644 index 000000000..1fef59318 --- /dev/null +++ b/edi_upflow/components/edi_output_check_upflow_post_credit_notes.py @@ -0,0 +1,14 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo.addons.component.core import Component + + +class EdiOutputCheckUpflowPostCreditNotes(Component): + _name = "edi.output.check.upflow_post_credit_notes" + _inherit = "base.upflow.edi.output.check" + _exchange_type = "upflow_post_credit_notes" + + def _check_and_process(self): + self._upflow_check_and_process() diff --git a/edi_upflow/components/edi_output_check_upflow_post_credit_notes_pdf.py b/edi_upflow/components/edi_output_check_upflow_post_credit_notes_pdf.py new file mode 100644 index 000000000..f13791a29 --- /dev/null +++ b/edi_upflow/components/edi_output_check_upflow_post_credit_notes_pdf.py @@ -0,0 +1,14 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo.addons.component.core import Component + + +class EdiOutputCheckUpflowPostCreditNotesPDF(Component): + _name = "edi.output.check.upflow_post_credit_notes_pdf" + _inherit = "base.upflow.edi.output.check" + _exchange_type = "upflow_post_credit_notes_pdf" + + def _check_and_process(self): + self._check_ws_response_status_code() diff --git a/edi_upflow/components/edi_output_check_upflow_post_customers.py b/edi_upflow/components/edi_output_check_upflow_post_customers.py new file mode 100644 index 000000000..7aa9ac0b6 --- /dev/null +++ b/edi_upflow/components/edi_output_check_upflow_post_customers.py @@ -0,0 +1,24 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import logging + +from odoo.addons.component.core import Component + +_logger = logging.getLogger(__name__) + + +class EdiOutputCheckUpflowPostCustomers(Component): + _name = "edi.output.check.upflow_post_customers" + _inherit = "base.upflow.edi.output.check" + _exchange_type = "upflow_post_customers" + + def _check_and_process(self): + self._upflow_check_and_process() + for contact in self._parse_upflow_response().get("contacts", []): + if not contact.get("externalId"): + _logger.warning("No externalId found for contact %s", contact.get("id")) + continue + self.exchange_record.record.child_ids.filtered_domain( + [("id", "=", int(contact.get("externalId")))] + ).upflow_uuid = contact.get("id") diff --git a/edi_upflow/components/edi_output_check_upflow_post_invoice.py b/edi_upflow/components/edi_output_check_upflow_post_invoice.py new file mode 100644 index 000000000..665b89407 --- /dev/null +++ b/edi_upflow/components/edi_output_check_upflow_post_invoice.py @@ -0,0 +1,14 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo.addons.component.core import Component + + +class EdiOutputCheckUpflowPostInvoice(Component): + _name = "edi.output.check.upflow_post_invoice" + _inherit = "base.upflow.edi.output.check" + _exchange_type = "upflow_post_invoice" + + def _check_and_process(self): + self._upflow_check_and_process() diff --git a/edi_upflow/components/edi_output_check_upflow_post_invoice_pdf.py b/edi_upflow/components/edi_output_check_upflow_post_invoice_pdf.py new file mode 100644 index 000000000..1dc466022 --- /dev/null +++ b/edi_upflow/components/edi_output_check_upflow_post_invoice_pdf.py @@ -0,0 +1,14 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo.addons.component.core import Component + + +class EdiOutputCheckUpflowPostInvoicePDF(Component): + _name = "edi.output.check.upflow_post_invoice_pdf" + _inherit = "base.upflow.edi.output.check" + _exchange_type = "upflow_post_invoice_pdf" + + def _check_and_process(self): + self._check_ws_response_status_code() diff --git a/edi_upflow/components/edi_output_check_upflow_post_payments.py b/edi_upflow/components/edi_output_check_upflow_post_payments.py new file mode 100644 index 000000000..5f8e29521 --- /dev/null +++ b/edi_upflow/components/edi_output_check_upflow_post_payments.py @@ -0,0 +1,14 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo.addons.component.core import Component + + +class EdiOutputCheckUpflowPostPayments(Component): + _name = "edi.output.check.upflow_post_payments" + _inherit = "base.upflow.edi.output.check" + _exchange_type = "upflow_post_payments" + + def _check_and_process(self): + self._upflow_check_and_process() diff --git a/edi_upflow/components/edi_output_check_upflow_post_reconcile.py b/edi_upflow/components/edi_output_check_upflow_post_reconcile.py new file mode 100644 index 000000000..6455082d3 --- /dev/null +++ b/edi_upflow/components/edi_output_check_upflow_post_reconcile.py @@ -0,0 +1,16 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo.addons.component.core import Component + + +class EdiOutputCheckUpflowPostReconcile(Component): + _name = "edi.output.check.upflow_post_reconcile" + _inherit = "base.upflow.edi.output.check" + _exchange_type = "upflow_post_reconcile" + + def _check_and_process(self): + self._check_ws_response_status_code() + if self.exchange_record.record: + self.exchange_record.record.sent_to_upflow = True diff --git a/edi_upflow/components/edi_output_check_upflow_post_refunds.py b/edi_upflow/components/edi_output_check_upflow_post_refunds.py new file mode 100644 index 000000000..4533795bf --- /dev/null +++ b/edi_upflow/components/edi_output_check_upflow_post_refunds.py @@ -0,0 +1,14 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo.addons.component.core import Component + + +class EdiOutputCheckUpflowPostRefunds(Component): + _name = "edi.output.check.upflow_post_refunds" + _inherit = "base.upflow.edi.output.check" + _exchange_type = "upflow_post_refunds" + + def _check_and_process(self): + self._upflow_check_and_process() diff --git a/edi_upflow/components/edi_output_check_upflow_put_contacts.py b/edi_upflow/components/edi_output_check_upflow_put_contacts.py new file mode 100644 index 000000000..ba0cc811c --- /dev/null +++ b/edi_upflow/components/edi_output_check_upflow_put_contacts.py @@ -0,0 +1,14 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo.addons.component.core import Component + + +class EdiOutputCheckUpflowPutContacts(Component): + _name = "edi.output.check.upflow_put_contacts" + _inherit = "base.upflow.edi.output.check" + _exchange_type = "upflow_put_contacts" + + def _check_and_process(self): + self._upflow_check_and_process() diff --git a/edi_upflow/components/edi_output_generate_upflow_post_credit_notes.py b/edi_upflow/components/edi_output_generate_upflow_post_credit_notes.py new file mode 100644 index 000000000..ecc41b0ff --- /dev/null +++ b/edi_upflow/components/edi_output_generate_upflow_post_credit_notes.py @@ -0,0 +1,18 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import json + +from odoo.addons.component.core import Component + + +class EdiOutputGenerateUpflowPostCreditNotes(Component): + _name = "edi.output.generate.upflow_post_credit_notes" + _inherit = "base.upflow.edi.output.generate" + _exchange_type = "upflow_post_credit_notes" + + def generate(self): + self._wait_related_exchange_to_be_sent_and_processed() + return json.dumps( + self.exchange_record.record.get_upflow_api_post_credit_note_payload() + ) diff --git a/edi_upflow/components/edi_output_generate_upflow_post_credit_notes_pdf.py b/edi_upflow/components/edi_output_generate_upflow_post_credit_notes_pdf.py new file mode 100644 index 000000000..dca4393c1 --- /dev/null +++ b/edi_upflow/components/edi_output_generate_upflow_post_credit_notes_pdf.py @@ -0,0 +1,16 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import json + +from odoo.addons.component.core import Component + + +class EdiOutputGenerateUpflowPostCreditNotesPDF(Component): + _name = "edi.output.generate.upflow_post_credit_notes_pdf" + _inherit = "base.upflow.edi.output.generate" + _exchange_type = "upflow_post_credit_notes_pdf" + + def generate(self): + self._wait_related_exchange_to_be_sent_and_processed() + return json.dumps(self.exchange_record.record.get_upflow_api_pdf_payload()) diff --git a/edi_upflow/components/edi_output_generate_upflow_post_customers.py b/edi_upflow/components/edi_output_generate_upflow_post_customers.py new file mode 100644 index 000000000..9f644c978 --- /dev/null +++ b/edi_upflow/components/edi_output_generate_upflow_post_customers.py @@ -0,0 +1,24 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import json + +from odoo.addons.component.core import Component + + +class EdiOutputGenerateUpflowPostCustomers(Component): + _name = "edi.output.generate.upflow_post_customers" + _inherit = "base.upflow.edi.output.generate" + _exchange_type = "upflow_post_customers" + + def generate(self): + if not self.record: + raise EdiOutputGenerateUpflowPostCustomersError( + "No record found to generate the payload." + ) + payload = self.record.get_upflow_api_post_customers_payload() + return json.dumps(payload) + + +class EdiOutputGenerateUpflowPostCustomersError(Exception): + pass diff --git a/edi_upflow/components/edi_output_generate_upflow_post_invoice.py b/edi_upflow/components/edi_output_generate_upflow_post_invoice.py new file mode 100644 index 000000000..2e633c4f5 --- /dev/null +++ b/edi_upflow/components/edi_output_generate_upflow_post_invoice.py @@ -0,0 +1,18 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import json + +from odoo.addons.component.core import Component + + +class EdiOutputGenerateUpflowPostInvoice(Component): + _name = "edi.output.generate.upflow_post_invoice" + _inherit = "base.upflow.edi.output.generate" + _exchange_type = "upflow_post_invoice" + + def generate(self): + self._wait_related_exchange_to_be_sent_and_processed() + return json.dumps( + self.exchange_record.record.get_upflow_api_post_invoice_payload() + ) diff --git a/edi_upflow/components/edi_output_generate_upflow_post_invoice_pdf.py b/edi_upflow/components/edi_output_generate_upflow_post_invoice_pdf.py new file mode 100644 index 000000000..e67d6b8c1 --- /dev/null +++ b/edi_upflow/components/edi_output_generate_upflow_post_invoice_pdf.py @@ -0,0 +1,16 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import json + +from odoo.addons.component.core import Component + + +class EdiOutputGenerateUpflowPostInvoicePDF(Component): + _name = "edi.output.generate.upflow_post_invoice_pdf" + _inherit = "base.upflow.edi.output.generate" + _exchange_type = "upflow_post_invoice_pdf" + + def generate(self): + self._wait_related_exchange_to_be_sent_and_processed() + return json.dumps(self.exchange_record.record.get_upflow_api_pdf_payload()) diff --git a/edi_upflow/components/edi_output_generate_upflow_post_payments.py b/edi_upflow/components/edi_output_generate_upflow_post_payments.py new file mode 100644 index 000000000..47fedda5e --- /dev/null +++ b/edi_upflow/components/edi_output_generate_upflow_post_payments.py @@ -0,0 +1,18 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import json + +from odoo.addons.component.core import Component + + +class EdiOutputGenerateUpflowPostPayments(Component): + _name = "edi.output.generate.upflow_post_payments" + _inherit = "base.upflow.edi.output.generate" + _exchange_type = "upflow_post_payments" + + def generate(self): + self._wait_related_exchange_to_be_sent_and_processed() + return json.dumps( + self.exchange_record.record.get_upflow_api_post_payment_payload() + ) diff --git a/edi_upflow/components/edi_output_generate_upflow_post_reconcile.py b/edi_upflow/components/edi_output_generate_upflow_post_reconcile.py new file mode 100644 index 000000000..6987562ee --- /dev/null +++ b/edi_upflow/components/edi_output_generate_upflow_post_reconcile.py @@ -0,0 +1,28 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import json + +from odoo.addons.component.core import Component + + +class EdiOutputGenerateUpflowPostReconcile(Component): + _name = "edi.output.generate.upflow_post_reconcile" + _inherit = "base.upflow.edi.output.generate" + _exchange_type = "upflow_post_reconcile" + + def generate(self): + self._wait_related_exchange_to_be_sent_and_processed() + if not self.record: + raise EdiOutputGenerateUpflowPostReconcileError( + "No record found to generate the payload." + ) + if self.env.context.get("unlinking_reconcile", False): + payload = self.record._prepare_reconcile_payload() + else: + payload = self.record.get_upflow_api_post_reconcile_payload() + return json.dumps(payload) + + +class EdiOutputGenerateUpflowPostReconcileError(Exception): + pass diff --git a/edi_upflow/components/edi_output_generate_upflow_post_refunds.py b/edi_upflow/components/edi_output_generate_upflow_post_refunds.py new file mode 100644 index 000000000..f60258b16 --- /dev/null +++ b/edi_upflow/components/edi_output_generate_upflow_post_refunds.py @@ -0,0 +1,18 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import json + +from odoo.addons.component.core import Component + + +class EdiOutputGenerateUpflowPostRefunds(Component): + _name = "edi.output.generate.upflow_post_refunds" + _inherit = "base.upflow.edi.output.generate" + _exchange_type = "upflow_post_refunds" + + def generate(self): + self._wait_related_exchange_to_be_sent_and_processed() + return json.dumps( + self.exchange_record.record.get_upflow_api_post_refund_payload() + ) diff --git a/edi_upflow/components/edi_output_generate_upflow_put_contacts.py b/edi_upflow/components/edi_output_generate_upflow_put_contacts.py new file mode 100644 index 000000000..1b692a4fa --- /dev/null +++ b/edi_upflow/components/edi_output_generate_upflow_put_contacts.py @@ -0,0 +1,17 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import json + +from odoo.addons.component.core import Component + + +class EdiOutputGenerateUpflowPutContacts(Component): + _name = "edi.output.generate.upflow_put_contacts" + _inherit = "base.upflow.edi.output.generate" + _exchange_type = "upflow_put_contacts" + + def generate(self): + return json.dumps( + self.exchange_record.record.get_upflow_api_post_contacts_payload() + ) diff --git a/edi_upflow/components/edi_webservice_send.py b/edi_upflow/components/edi_webservice_send.py new file mode 100644 index 000000000..d032d5882 --- /dev/null +++ b/edi_upflow/components/edi_webservice_send.py @@ -0,0 +1,29 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from collections import defaultdict + +from odoo.addons.component.core import Component + + +class EDIWebserviceSend(Component): + + _name = "edi.webservice.send" + _inherit = "edi.webservice.send" + + def _get_call_params(self): + method, pargs, kwargs = super()._get_call_params() + upflow_uuid = getattr(self.exchange_record.record, "upflow_uuid", None) + commercial_partner_id = getattr( + self.exchange_record.record, "commercial_partner_id", None + ) + kwargs["url_params"]["endpoint"] = kwargs["url_params"]["endpoint"].format_map( + defaultdict( + str, + upflow_uuid=upflow_uuid, + commercial_upflow_uuid=commercial_partner_id.upflow_uuid + if commercial_partner_id + else None, + ) + ) + return method, pargs, kwargs diff --git a/edi_upflow/components/event_listener_account_move.py b/edi_upflow/components/event_listener_account_move.py new file mode 100644 index 000000000..97be015ea --- /dev/null +++ b/edi_upflow/components/event_listener_account_move.py @@ -0,0 +1,20 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo.addons.component.core import Component + + +class AccountMoveUpflowEventListener(Component): + """Intent of this class is to listen interesting account.move events + + used to create usefull exchange record for followup external system + with upflow.io in mind (not sure it can be as generic) + """ + + _name = "account.move.upflow.event.listener" + _inherit = "base.upflow.event.listener" + _apply_on = ["account.move"] + + def on_post_account_move(self, moves): + self.send_moves_to_upflow(moves) diff --git a/edi_upflow/components/event_listener_account_partial_reconcile.py b/edi_upflow/components/event_listener_account_partial_reconcile.py new file mode 100644 index 000000000..f25bdc8ec --- /dev/null +++ b/edi_upflow/components/event_listener_account_partial_reconcile.py @@ -0,0 +1,176 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import logging + +from odoo import _ +from odoo.exceptions import UserError + +from odoo.addons.component.core import Component + +logger = logging.getLogger(__name__) + + +class AccountPartialReconcileUpflowEventListener(Component): + + _name = "account.partial.reconcile.upflow.event.listener" + _inherit = "base.upflow.event.listener" + _apply_on = ["account.partial.reconcile"] + + def _filter_relevant_account_event_state_method(self, states): + """Only accounting event (ignore pdf events)""" + event_types = ( + self.env.ref("edi_upflow.upflow_edi_exchange_type_post_invoices"), + self.env.ref("edi_upflow.upflow_edi_exchange_type_post_credit_notes"), + self.env.ref("edi_upflow.upflow_edi_exchange_type_post_payments"), + self.env.ref("edi_upflow.upflow_edi_exchange_type_post_refunds"), + ) + return ( + lambda ex, event_types=event_types, states=states: ex.type_id in event_types + and ex.edi_exchange_state in states + ) + + def _ensure_related_move_is_synced(self, reconcile_exchange, move): + "output_sent_and_processed" + ongoing_move_exchanges = move.exchange_record_ids.filtered( + self._filter_relevant_account_event_state_method( + [ + "new", + "output_pending", + "output_sent", + ] + ) + ) + finalized_move_exchanges = move.exchange_record_ids.filtered( + self._filter_relevant_account_event_state_method( + [ + "output_sent_and_processed", + ] + ) + ) + if move.upflow_uuid or ongoing_move_exchanges: + # we don't know if an error happens on the first exchange + # then user manage to create an other exchange that passed + # we only link on going exchange and processed + # and there is a good change that the event will raise anyway + reconcile_exchange.dependent_exchange_ids |= ( + ongoing_move_exchanges | finalized_move_exchanges + ) + else: + self._create_missing_exchange_record(reconcile_exchange, move) + + def _create_missing_exchange_record(self, reconcile_exchange, move): + if move.upflow_commercial_partner_id: + # create payment from bank statements + # do not necessarily generate account.payment + + # At this point we expect customer to be already synchronized + reconcile_exchange.dependent_exchange_ids |= self.send_moves_to_upflow(move) + else: + raise UserError( + _( + "You can reconcile journal items because the journal entry " + "%s (ID: %s) is not synchronisable with upflow.io, " + "because partner is not set but required." + ) + % ( + move.name, + move.id, + ) + ) + + def _is_customer_entry(self, reconcile): + # both should share the same type anyway + return ( + reconcile.debit_move_id.account_id.user_type_id.type == "receivable" + or reconcile.credit_move_id.account_id.user_type_id.type == "receivable" + ) + + def _get_reconcile_partner(self, account_partial_reconcile): + """credit/debit move line should be link to the same partner + and partner_id should be present on receivable account.move.line + + Anyway we try to be kind here and find + """ + return ( + account_partial_reconcile.debit_move_id.partner_id.commercial_partner_id + or account_partial_reconcile.credit_move_id.partner_id.commercial_partner_id + or account_partial_reconcile.debit_move_id.move_id.commercial_partner_id + or account_partial_reconcile.credit_move_id.move_id.commercial_partner_id + ) + + def _get_backend(self, account_partial_reconcile): + partner = self._get_reconcile_partner(account_partial_reconcile) + return partner.upflow_edi_backend_id or self._get_followup_backend( + account_partial_reconcile.debit_move_id.move_id + ) + + def on_record_create(self, account_partial_reconcile, fields=None): + if not self._is_customer_entry(account_partial_reconcile): + return + + reconcile_exchange = self._create_and_generate_upflow_exchange_record( + self._get_backend(account_partial_reconcile), + "upflow_post_reconcile", + account_partial_reconcile, + ) + if not reconcile_exchange: + # in case no backend is returned there are nothing to do + return + self._ensure_related_move_is_synced( + reconcile_exchange, + account_partial_reconcile.credit_move_id.move_id, + ) + self._ensure_related_move_is_synced( + reconcile_exchange, + account_partial_reconcile.debit_move_id.move_id, + ) + + def on_record_unlink(self, account_partial_reconcile): + if not account_partial_reconcile.sent_to_upflow: + self._delete_exchanges_and_cancel_jobs(account_partial_reconcile) + return + # we are not using _create_and_generate_upflow_exchange_record + # here because we want to generate payload synchronously + # after wards record will be unlinked with no chance to retrieves + # upflow_uuid + backend = self._get_backend(account_partial_reconcile) + if not backend: + return + exchange_record = backend.create_record( + "upflow_post_reconcile", + self._get_exchange_record_vals(account_partial_reconcile), + ) + exchange_record.with_context( + unlinking_reconcile=True + ).action_exchange_generate() + + def _delete_exchanges_and_cancel_jobs(self, account_partial_reconcile): + odoobot = self.env.ref("base.partner_root") + message_body = _( + "This job has been canceled and its associated EDI exchange was deleted " + "because the associated reconciliation was deleted" + ) + + exchanges_to_delete = account_partial_reconcile.exchange_record_ids.filtered( + lambda exchange: exchange.edi_exchange_state == "new" + ) + queue_job_model = self.env["queue.job"] + channel = self.env.ref("edi_oca.channel_edi_exchange") + for exchange_to_delete in exchanges_to_delete: + exchange_to_delete.unlink() + queue_jobs_to_cancel = queue_job_model.search( + [ + ("job_function_id.channel_id", "=", channel.id), + ("state", "=", "pending"), + ] + ).filtered(lambda queue_job: queue_job.records.id == exchange_to_delete.id) + if not queue_jobs_to_cancel: + continue + queue_jobs_to_cancel.button_cancelled() + queue_jobs_to_cancel.message_post( + body=message_body, + message_type="comment", + subtype_xmlid="mail.mt_note", + author_id=odoobot.id, + ) diff --git a/edi_upflow/components/event_listener_base.py b/edi_upflow/components/event_listener_base.py new file mode 100644 index 000000000..2146b9089 --- /dev/null +++ b/edi_upflow/components/event_listener_base.py @@ -0,0 +1,81 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import logging + +from odoo.addons.component.core import Component + +log = logging.getLogger(__name__) + + +class BaseUpflowEventListner(Component): + + _name = "base.upflow.event.listener" + _inherit = "base.event.listener" + + def _get_followup_backend(self, record): + return record.company_id.upflow_backend_id + + def _get_exchange_record_vals(self, record): + return { + "model": record._name, + "res_id": record.id, + } + + def _create_and_generate_upflow_exchange_record( + self, backend, exchange_type, record + ): + if backend: + exchange_record = backend.create_record( + exchange_type, self._get_exchange_record_vals(record) + ) + exchange_record.with_delay().action_exchange_generate() + else: + return self.env["edi.exchange.record"] + return exchange_record + + def send_moves_to_upflow(self, moves): + move_exchanges = self.env["edi.exchange.record"].browse() + # if a lot of things happen in this same transaction probably + # depends are not computed yet better to refresh value + moves._compute_upflow_type() + for move in moves: + if not move.upflow_commercial_partner_id: + log.warning( + "No Upflow commercial partner found for move %s, ignoring", move + ) + continue + exchange_type, pdf_exchange = move.mapping_upflow_exchange().get( + move.upflow_type, + ( + None, + None, + ), + ) + + if not exchange_type: + continue + customer_exchange = self.env["edi.exchange.record"] + backend = ( + move.upflow_commercial_partner_id.upflow_edi_backend_id + or self._get_followup_backend(move) + ) + if not move.upflow_commercial_partner_id.upflow_uuid: + customer_exchange = self._create_and_generate_upflow_exchange_record( + backend, "upflow_post_customers", move.upflow_commercial_partner_id + ) + move.upflow_commercial_partner_id.upflow_edi_backend_id = backend + account_move_exchange = self._create_and_generate_upflow_exchange_record( + backend, exchange_type, move + ) + if not account_move_exchange: + # empty recordset could be return in case no backend found + continue + account_move_exchange.dependent_exchange_ids |= customer_exchange + if pdf_exchange: + pdf_exchange = self._create_and_generate_upflow_exchange_record( + backend, pdf_exchange, move + ) + pdf_exchange.dependent_exchange_ids |= account_move_exchange + move_exchanges |= account_move_exchange + return move_exchanges diff --git a/edi_upflow/components/event_listener_exchange_record.py b/edi_upflow/components/event_listener_exchange_record.py new file mode 100644 index 000000000..b617af4d7 --- /dev/null +++ b/edi_upflow/components/event_listener_exchange_record.py @@ -0,0 +1,32 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo.addons.component.core import Component + + +class ExchangeRecordUpflowEventListener(Component): + """Intent of this class is to listen others exchange + + queue job task to create the next one and speed-up synchro + """ + + _name = "exchange.record.upflow.event.listener" + _inherit = "base.event.listener" + _apply_on = ["edi.exchange.record"] + + def on_edi_exchange_generate_complete(self, exchange_record): + """save time creating task right away and do no wait cron task""" + if exchange_record.backend_id.backend_type_id.code == "upflow": + exchange_record.with_delay().action_exchange_send() + + def on_edi_exchange_send_complete(self, exchange_record): + if ( + exchange_record.backend_id.backend_type_id.code == "upflow" + and exchange_record.ws_response_status_code >= 200 + and exchange_record.ws_response_status_code < 300 + ): + # speed up post treatment creating job right away avoiding to wait cron task + exchange_record.backend_id.with_delay()._exchange_output_check_state( + exchange_record + ) diff --git a/edi_upflow/components/event_listener_res_partner.py b/edi_upflow/components/event_listener_res_partner.py new file mode 100644 index 000000000..0f2f744c7 --- /dev/null +++ b/edi_upflow/components/event_listener_res_partner.py @@ -0,0 +1,94 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import logging + +from odoo.addons.component.core import Component + +logger = logging.getLogger(__name__) + + +class ResPartnerUpflowEventListener(Component): + + _name = "res.partner.upflow.event.listener" + _inherit = "base.upflow.event.listener" + _apply_on = ["res.partner"] + + def on_record_create(self, partner, fields=None): + if ( + partner.commercial_partner_id.upflow_uuid + and partner.commercial_partner_id.upflow_edi_backend_id + ): + self._create_and_generate_upflow_exchange_record( + partner.commercial_partner_id.upflow_edi_backend_id, + "upflow_post_customers", + partner.commercial_partner_id, + ) + + def on_record_write(self, partner, fields=None): + update_contact_fields = set(self.env["res.partner"].get_upflow_contact_fields()) + update_customer_fields = set( + self.env["res.partner"].get_upflow_customer_fields() + ) + update_contact_fields_from_customer = set( + self.env["res.partner"].get_upflow_customer_fields_to_update_contacts() + ) + update_customer_fields_from_contact = set( + self.env["res.partner"].get_upflow_contact_fields_to_update_customer() + ) + if ( + not partner.commercial_partner_id.upflow_uuid + or not partner.commercial_partner_id.upflow_edi_backend_id + ): + return + # Creating/Updating customer and Creating contact + if ( + (not partner.parent_id and set(fields) & update_customer_fields) + or (partner.parent_id and set(fields) & update_customer_fields_from_contact) + or ( + partner.parent_id + and not partner.upflow_uuid + and set(fields) & update_contact_fields + ) + ): + self._create_and_generate_upflow_exchange_record( + partner.commercial_partner_id.upflow_edi_backend_id, + "upflow_post_customers", + partner.commercial_partner_id, + ) + # Updating contact + if ( + partner.upflow_uuid + and partner.parent_id + and set(fields) & update_contact_fields + ): + self._create_and_generate_upflow_exchange_record( + partner.commercial_partner_id.upflow_edi_backend_id, + "upflow_put_contacts", + partner, + ) + if not partner.parent_id: + contact_to_update = self.env["res.partner"].browse() + for child in { # This only works for many2one fields + partner[element] + for element in set(fields) & update_contact_fields_from_customer + }: + contact_to_update |= child + for contact in list(set(contact_to_update)): + self._create_and_generate_upflow_exchange_record( + partner.commercial_partner_id.upflow_edi_backend_id, + "upflow_put_contacts", + contact, + ) + + def on_record_unlink(self, partner): + # TODO: manage removing customer (not only a contact) + if ( + partner.commercial_partner_id.upflow_uuid + and partner.commercial_partner_id.upflow_edi_backend_id + ): + self._create_and_generate_upflow_exchange_record( + partner.commercial_partner_id.upflow_edi_backend_id, + "upflow_post_customers", + partner.commercial_partner_id, + ) diff --git a/edi_upflow/components/request_adapter.py b/edi_upflow/components/request_adapter.py new file mode 100644 index 000000000..3f1b883c2 --- /dev/null +++ b/edi_upflow/components/request_adapter.py @@ -0,0 +1,15 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.addons.component.core import Component + + +class BaseRestRequestsAdapter(Component): + + _inherit = "base.request" + + def _get_headers_for_upflow(self, **kw): + return { + "X-Api-Key": self.collection.upflow_api_key, + "X-Api-Secret": self.collection.upflow_api_secret, + } diff --git a/edi_upflow/data/cron.xml b/edi_upflow/data/cron.xml new file mode 100644 index 000000000..10a6e9506 --- /dev/null +++ b/edi_upflow/data/cron.xml @@ -0,0 +1,12 @@ + + + + + + + model.search([])._cron_check_output_exchange_sync(skip_sent=False) + + + diff --git a/edi_upflow/data/edi.xml b/edi_upflow/data/edi.xml new file mode 100644 index 000000000..99c130724 --- /dev/null +++ b/edi_upflow/data/edi.xml @@ -0,0 +1,379 @@ + + + + + {1: 5, 3: 30, 6: 60, 10: 600} + + + + Upflow webservice backend (Sandbox) + https://api.sandbox.upflow.io/{endpoint} + upflow_ws + http + application/json + + none + + + + + Upflow.io backend type + upflow + + + EDI upflow backend WebService + + + False + + + Send invoice to create or update (POST v1/invoices) + upflow_post_invoice + + + output + False + json + + components: + send: + usage: webservice.send + work_ctx: + webservice: + method: post + kwargs: + url_params: + endpoint: v1/invoices + + + + + Default Send invoice to create or update rule + + + custom + + + + Send PDF invoice (POST v1/invoices/{upflow_uuid}/pdf) + upflow_post_invoice_pdf + + + output + False + json + + components: + send: + usage: webservice.send + work_ctx: + webservice: + method: post + kwargs: + url_params: + endpoint: v1/invoices/{upflow_uuid}/pdf + + + + + Default Send PDF invoice rule + + + custom + + + + + Send Odoo refunds (upflow CreditNotes) to create or update + (POST v1/credit_notes) + + upflow_post_credit_notes + + + output + False + json + + components: + send: + usage: webservice.send + work_ctx: + webservice: + method: post + kwargs: + url_params: + endpoint: v1/credit_notes + + + + + Default Send Odoo refunds (upflow CreditNotes) to create or update rule + + + custom + + + + + Send Odoo refunds pdf (upflow CreditNotes) + (POST v1/credit_notes/{upflow_uuid}/pdf) + + upflow_post_credit_notes_pdf + + + output + False + json + + components: + send: + usage: webservice.send + work_ctx: + webservice: + method: post + kwargs: + url_params: + endpoint: v1/credit_notes/{upflow_uuid}/pdf + + + + + Default Send Odoo refunds pdf (upflow CreditNotes) rule + + + custom + + + + + Send customer payments to create or update + (POST v1/payments) + + upflow_post_payments + + + output + False + json + + components: + send: + usage: webservice.send + work_ctx: + webservice: + method: post + kwargs: + url_params: + endpoint: v1/payments + + + + + Default send customer payments to create or update rule + + + custom + + + + + Send refunds (credit note payments) to create or update + (POST v1/refunds) + + upflow_post_refunds + + + output + False + json + + components: + send: + usage: webservice.send + work_ctx: + webservice: + method: post + kwargs: + url_params: + endpoint: v1/refunds + + + + + Default send refunds (credit note payments) to create or update rule + + + custom + + + + + Send reconcile between account entries + (POST v1/reconcile) + + upflow_post_reconcile + + + output + False + json + + components: + send: + usage: webservice.send + work_ctx: + webservice: + method: post + kwargs: + url_params: + endpoint: v1/reconcile + + + + + Default send reconcile between account entries rule + + + custom + + + + + Send/update customers and contacts + (POST v1/upflow_post_customers) + + upflow_post_customers + + + output + False + json + + components: + send: + usage: webservice.send + work_ctx: + webservice: + method: post + kwargs: + url_params: + endpoint: v1/customers + + + + + Default Send/update customers and contacts rule + + + custom + + + + + Update contacts + (Put v1/upflow_put_contacts) + + upflow_put_contacts + + + output + False + json + + components: + send: + usage: webservice.send + work_ctx: + webservice: + method: put + kwargs: + url_params: + endpoint: v1/customers/{commercial_upflow_uuid}/contacts/{upflow_uuid} + + + + + Default Update contacts rule + + + custom + + + + + diff --git a/edi_upflow/i18n/edi_upflow.pot b/edi_upflow/i18n/edi_upflow.pot new file mode 100644 index 000000000..b20b1b034 --- /dev/null +++ b/edi_upflow/i18n/edi_upflow.pot @@ -0,0 +1,404 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * edi_upflow +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0+e\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: edi_upflow +#: model_terms:ir.ui.view,arch_db:edi_upflow.res_config_settings_view_form +msgid "Upflow backend" +msgstr "" + +#. module: edi_upflow +#: model_terms:ir.ui.view,arch_db:edi_upflow.account_partial_reconcile_form_view +#: model_terms:ir.ui.view,arch_db:edi_upflow.view_partner_form +msgid "EDI" +msgstr "" + +#. module: edi_upflow +#: model:ir.actions.act_window,name:edi_upflow.account_partial_reconcile_action +msgid "Account partial reconcile" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__message_needaction +msgid "Action Needed" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__message_attachment_count +msgid "Attachment Count" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_webservice_backend__auth_type +msgid "Auth Type" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,help:edi_upflow.field_res_partner__upflow_edi_backend_id +#: model:ir.model.fields,help:edi_upflow.field_res_users__upflow_edi_backend_id +msgid "" +"Backend used to synchronised this partner to upflow. Technical field as " +"while updating existing customer we are not able to determine which upflow " +"backend to use. There are some limitation, a customer can't be synchronized " +"in two different backends. A customer used by 2 different company (multi-" +"company odoo feature) manage in two upflow organisations is not support " +"today. So we direct link upflow backend here !" +msgstr "" + +#. module: edi_upflow +#: model:ir.model,name:edi_upflow.model_res_company +msgid "Companies" +msgstr "" + +#. module: edi_upflow +#: model:ir.model,name:edi_upflow.model_res_config_settings +msgid "Config Settings" +msgstr "" + +#. module: edi_upflow +#: model:ir.model,name:edi_upflow.model_res_partner +msgid "Contact" +msgstr "" + +#. module: edi_upflow +#: model_terms:ir.ui.view,arch_db:edi_upflow.edi_exchange_record_view_form +msgid "Dependent exchanges" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_edi_exchange_record__dependent_exchange_ids +msgid "Depends on" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__disable_edi_auto +#: model:ir.model.fields,field_description:edi_upflow.field_res_partner__disable_edi_auto +msgid "Disable auto" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_move__display_name +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__display_name +#: model:ir.model.fields,field_description:edi_upflow.field_edi_exchange_record__display_name +#: model:ir.model.fields,field_description:edi_upflow.field_res_company__display_name +#: model:ir.model.fields,field_description:edi_upflow.field_res_config_settings__display_name +#: model:ir.model.fields,field_description:edi_upflow.field_res_partner__display_name +#: model:ir.model.fields,field_description:edi_upflow.field_webservice_backend__display_name +msgid "Display Name" +msgstr "" + +#. module: edi_upflow +#: model:ir.model,name:edi_upflow.model_edi_exchange_record +msgid "EDI exchange Record" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__origin_exchange_type_id +#: model:ir.model.fields,field_description:edi_upflow.field_res_partner__origin_exchange_type_id +msgid "EDI origin exchange type" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__origin_exchange_record_id +#: model:ir.model.fields,field_description:edi_upflow.field_res_partner__origin_exchange_record_id +msgid "EDI origin record" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,help:edi_upflow.field_account_partial_reconcile__origin_exchange_record_id +#: model:ir.model.fields,help:edi_upflow.field_res_partner__origin_exchange_record_id +msgid "EDI record that originated this document." +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__edi_config +#: model:ir.model.fields,field_description:edi_upflow.field_res_partner__edi_config +msgid "Edi Config" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__edi_has_form_config +#: model:ir.model.fields,field_description:edi_upflow.field_res_partner__edi_has_form_config +msgid "Edi Has Form Config" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__exchange_record_ids +#: model:ir.model.fields,field_description:edi_upflow.field_res_partner__exchange_record_ids +msgid "Exchange Record" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__exchange_record_count +#: model:ir.model.fields,field_description:edi_upflow.field_res_partner__exchange_record_count +msgid "Exchange Record Count" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__message_follower_ids +msgid "Followers" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__message_channel_ids +msgid "Followers (Channels)" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__message_partner_ids +msgid "Followers (Partners)" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,help:edi_upflow.field_edi_exchange_record__dependent_exchange_ids +msgid "" +"Following exchanges have to be processed before be able to run the current " +"one." +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_move__id +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__id +#: model:ir.model.fields,field_description:edi_upflow.field_edi_exchange_record__id +#: model:ir.model.fields,field_description:edi_upflow.field_res_company__id +#: model:ir.model.fields,field_description:edi_upflow.field_res_config_settings__id +#: model:ir.model.fields,field_description:edi_upflow.field_res_partner__id +#: model:ir.model.fields,field_description:edi_upflow.field_webservice_backend__id +msgid "ID" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,help:edi_upflow.field_account_partial_reconcile__message_needaction +#: model:ir.model.fields,help:edi_upflow.field_account_partial_reconcile__message_unread +msgid "If checked, new messages require your attention." +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,help:edi_upflow.field_account_partial_reconcile__message_has_error +#: model:ir.model.fields,help:edi_upflow.field_account_partial_reconcile__message_has_sms_error +msgid "If checked, some messages have a delivery error." +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__message_is_follower +msgid "Is Follower" +msgstr "" + +#. module: edi_upflow +#: model:ir.model,name:edi_upflow.model_account_move +msgid "Journal Entry" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_move____last_update +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile____last_update +#: model:ir.model.fields,field_description:edi_upflow.field_edi_exchange_record____last_update +#: model:ir.model.fields,field_description:edi_upflow.field_res_company____last_update +#: model:ir.model.fields,field_description:edi_upflow.field_res_config_settings____last_update +#: model:ir.model.fields,field_description:edi_upflow.field_res_partner____last_update +#: model:ir.model.fields,field_description:edi_upflow.field_webservice_backend____last_update +msgid "Last Modified on" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__message_main_attachment_id +msgid "Main Attachment" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__message_has_error +msgid "Message Delivery error" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__message_ids +msgid "Messages" +msgstr "" + +#. module: edi_upflow +#: code:addons/edi_upflow/components/base_upflow_edi_output_check.py:0 +#, python-format +msgid "" +"Not a valid HTTP error (expected 2xx, received %s) in order to processed the" +" payload (%s)" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__message_needaction_counter +msgid "Number of Actions" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__message_has_error_counter +msgid "Number of errors" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,help:edi_upflow.field_account_partial_reconcile__message_needaction_counter +msgid "Number of messages which requires an action" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,help:edi_upflow.field_account_partial_reconcile__message_has_error_counter +msgid "Number of messages with delivery error" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,help:edi_upflow.field_account_partial_reconcile__message_unread_counter +msgid "Number of unread messages" +msgstr "" + +#. module: edi_upflow +#: model:ir.model,name:edi_upflow.model_account_partial_reconcile +msgid "Partial Reconcile" +msgstr "" + +#. module: edi_upflow +#: model:ir.ui.menu,name:edi_upflow.menu_account_config +msgid "Partial reconcile" +msgstr "" + +#. module: edi_upflow +#: model_terms:ir.ui.view,arch_db:edi_upflow.account_partial_reconcile_form_view +msgid "Reconcile part" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__message_has_sms_error +msgid "SMS Delivery error" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__sent_to_upflow +msgid "Sent To Upflow" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,help:edi_upflow.field_account_partial_reconcile__sent_to_upflow +msgid "" +"Technical field to know if the record has been synchronized with upflow" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,help:edi_upflow.field_account_bank_statement_line__upflow_type +#: model:ir.model.fields,help:edi_upflow.field_account_move__upflow_type +#: model:ir.model.fields,help:edi_upflow.field_account_payment__upflow_type +msgid "" +"Technical fields to make sure consistency while sending Journal entry and " +"reconcile payloads. Key values are current payload keys while sending " +"reconcile. While creating malicious entries it can be hard to automatically " +"choose proper type" +msgstr "" + +#. module: edi_upflow +#: code:addons/edi_upflow/components/event_listener_account_partial_reconcile.py:0 +#, python-format +msgid "" +"This job has been canceled and its associated EDI exchange was deleted " +"because the associated reconciliation was deleted" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__message_unread +msgid "Unread Messages" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__message_unread_counter +msgid "Unread Messages Counter" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_webservice_backend__upflow_api_key +msgid "Upflow API Key" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_webservice_backend__upflow_api_secret +msgid "Upflow API Secret" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_res_partner__upflow_edi_backend_id +#: model:ir.model.fields,field_description:edi_upflow.field_res_users__upflow_edi_backend_id +msgid "Upflow Edi Backend" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_bank_statement_line__upflow_type +#: model:ir.model.fields,field_description:edi_upflow.field_account_move__upflow_type +#: model:ir.model.fields,field_description:edi_upflow.field_account_payment__upflow_type +msgid "Upflow Type" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_res_company__upflow_backend_id +#: model:ir.model.fields,field_description:edi_upflow.field_res_config_settings__upflow_backend_id +msgid "Upflow backend" +msgstr "" + +#. module: edi_upflow +#: model_terms:ir.ui.view,arch_db:edi_upflow.res_config_settings_view_form +msgid "Upflow backend (on the current company)" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields.selection,name:edi_upflow.selection__webservice_backend__auth_type__upflow +msgid "Upflow.io" +msgstr "" + +#. module: edi_upflow +#: model:ir.model,name:edi_upflow.model_webservice_backend +msgid "WebService Backend" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__website_message_ids +msgid "Website Messages" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,help:edi_upflow.field_account_partial_reconcile__website_message_ids +msgid "Website communication history" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,help:edi_upflow.field_account_partial_reconcile__disable_edi_auto +#: model:ir.model.fields,help:edi_upflow.field_res_partner__disable_edi_auto +msgid "When marked, EDI automatic processing will be avoided" +msgstr "" + +#. module: edi_upflow +#: code:addons/edi_upflow/components/event_listener_account_partial_reconcile.py:0 +#, python-format +msgid "" +"You can reconcile journal items because the journal entry %s (ID: %s) is not" +" synchronisable with upflow.io, because partner is not set but required." +msgstr "" + +#. module: edi_upflow +#: code:addons/edi_upflow/components/base_upflow_edi_output_check.py:0 +#, python-format +msgid "" +"You should implement _check_and_process method to process upflow.io REST " +"response on this exchange type (code: %s)" +msgstr "" + +#. module: edi_upflow +#: code:addons/edi_upflow/components/base_upflow_edi_output_generate.py:0 +#, python-format +msgid "" +"You should implement generate method to create the payload or fix the " +"exchange type. (Received exchange type code: %s)" +msgstr "" diff --git a/edi_upflow/i18n/fr.po b/edi_upflow/i18n/fr.po new file mode 100644 index 000000000..a708ac768 --- /dev/null +++ b/edi_upflow/i18n/fr.po @@ -0,0 +1,421 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * edi_upflow +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0+e\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: edi_upflow +#: model_terms:ir.ui.view,arch_db:edi_upflow.res_config_settings_view_form +msgid "Upflow backend" +msgstr "Back office Upflow" + +#. module: edi_upflow +#: model_terms:ir.ui.view,arch_db:edi_upflow.account_partial_reconcile_form_view +#: model_terms:ir.ui.view,arch_db:edi_upflow.view_partner_form +msgid "EDI" +msgstr "EDI" + +#. module: edi_upflow +#: model:ir.actions.act_window,name:edi_upflow.account_partial_reconcile_action +msgid "Account partial reconcile" +msgstr "Lettrage partiel" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__message_needaction +msgid "Action Needed" +msgstr "Action nécessaire" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__message_attachment_count +msgid "Attachment Count" +msgstr "Nombre de pièces jointes" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_webservice_backend__auth_type +msgid "Auth Type" +msgstr "Type d'authentification" + +#. module: edi_upflow +#: model:ir.model.fields,help:edi_upflow.field_res_partner__upflow_edi_backend_id +#: model:ir.model.fields,help:edi_upflow.field_res_users__upflow_edi_backend_id +msgid "" +"Backend used to synchronised this partner to upflow. Technical field as " +"while updating existing customer we are not able to determine which upflow " +"backend to use. There are some limitation, a customer can't be synchronized " +"in two different backends. A customer used by 2 different company (multi-" +"company odoo feature) manage in two upflow organisations is not support " +"today. So we direct link upflow backend here !" +msgstr "" +"Backend utilisé pour la synchronisation avec upflow. Champ technique quand " +"on change un client/contact il n'est pas possible de déterminer quel backend " +"upflow est utilisé. Actuellement un client ne peut être lié qu'à une seule " +"organisation upflow." + +#. module: edi_upflow +#: model:ir.model,name:edi_upflow.model_res_company +msgid "Companies" +msgstr "Sociétés" + +#. module: edi_upflow +#: model:ir.model,name:edi_upflow.model_res_config_settings +msgid "Config Settings" +msgstr "Paramètres" + +#. module: edi_upflow +#: model:ir.model,name:edi_upflow.model_res_partner +msgid "Contact" +msgstr "Contact" + +#. module: edi_upflow +#: model_terms:ir.ui.view,arch_db:edi_upflow.edi_exchange_record_view_form +msgid "Dependent exchanges" +msgstr "Echanges de données dépendants" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_edi_exchange_record__dependent_exchange_ids +msgid "Depends on" +msgstr "Dépend de" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__disable_edi_auto +#: model:ir.model.fields,field_description:edi_upflow.field_res_partner__disable_edi_auto +msgid "Disable auto" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_move__display_name +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__display_name +#: model:ir.model.fields,field_description:edi_upflow.field_edi_exchange_record__display_name +#: model:ir.model.fields,field_description:edi_upflow.field_res_company__display_name +#: model:ir.model.fields,field_description:edi_upflow.field_res_config_settings__display_name +#: model:ir.model.fields,field_description:edi_upflow.field_res_partner__display_name +#: model:ir.model.fields,field_description:edi_upflow.field_webservice_backend__display_name +msgid "Display Name" +msgstr "Nom affiché" + +#. module: edi_upflow +#: model:ir.model,name:edi_upflow.model_edi_exchange_record +msgid "EDI exchange Record" +msgstr "Enregistrement d'échage de données informatisé (EDI)" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__origin_exchange_type_id +#: model:ir.model.fields,field_description:edi_upflow.field_res_partner__origin_exchange_type_id +msgid "EDI origin exchange type" +msgstr "Type d'EDI" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__origin_exchange_record_id +#: model:ir.model.fields,field_description:edi_upflow.field_res_partner__origin_exchange_record_id +msgid "EDI origin record" +msgstr "Enregistrement origine" + +#. module: edi_upflow +#: model:ir.model.fields,help:edi_upflow.field_account_partial_reconcile__origin_exchange_record_id +#: model:ir.model.fields,help:edi_upflow.field_res_partner__origin_exchange_record_id +msgid "EDI record that originated this document." +msgstr "Enregistrement EDI origine de ce document." + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__edi_config +#: model:ir.model.fields,field_description:edi_upflow.field_res_partner__edi_config +msgid "Edi Config" +msgstr "Configuration EDI" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__edi_has_form_config +#: model:ir.model.fields,field_description:edi_upflow.field_res_partner__edi_has_form_config +msgid "Edi Has Form Config" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__exchange_record_ids +#: model:ir.model.fields,field_description:edi_upflow.field_res_partner__exchange_record_ids +msgid "Exchange Record" +msgstr "Echanges EDI" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__exchange_record_count +#: model:ir.model.fields,field_description:edi_upflow.field_res_partner__exchange_record_count +msgid "Exchange Record Count" +msgstr "Nombre d'échanges EDI" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__message_follower_ids +msgid "Followers" +msgstr "Abonnés" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__message_channel_ids +msgid "Followers (Channels)" +msgstr "Abonnés (Canaux)" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__message_partner_ids +msgid "Followers (Partners)" +msgstr "Abonnés (Partenaires)" + +#. module: edi_upflow +#: model:ir.model.fields,help:edi_upflow.field_edi_exchange_record__dependent_exchange_ids +msgid "" +"Following exchanges have to be processed before be able to run the current " +"one." +msgstr "Les echanges EDI suivant doivent être traité avant l'echange actuel." + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_move__id +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__id +#: model:ir.model.fields,field_description:edi_upflow.field_edi_exchange_record__id +#: model:ir.model.fields,field_description:edi_upflow.field_res_company__id +#: model:ir.model.fields,field_description:edi_upflow.field_res_config_settings__id +#: model:ir.model.fields,field_description:edi_upflow.field_res_partner__id +#: model:ir.model.fields,field_description:edi_upflow.field_webservice_backend__id +msgid "ID" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,help:edi_upflow.field_account_partial_reconcile__message_needaction +#: model:ir.model.fields,help:edi_upflow.field_account_partial_reconcile__message_unread +msgid "If checked, new messages require your attention." +msgstr "Si coché, de nouveaux messages demandent votre attention." + +#. module: edi_upflow +#: model:ir.model.fields,help:edi_upflow.field_account_partial_reconcile__message_has_error +#: model:ir.model.fields,help:edi_upflow.field_account_partial_reconcile__message_has_sms_error +msgid "If checked, some messages have a delivery error." +msgstr "Si actif, certains messages ont une erreur de livraison." + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__message_is_follower +msgid "Is Follower" +msgstr "" + +#. module: edi_upflow +#: model:ir.model,name:edi_upflow.model_account_move +msgid "Journal Entry" +msgstr "Pièce comptable" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_move____last_update +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile____last_update +#: model:ir.model.fields,field_description:edi_upflow.field_edi_exchange_record____last_update +#: model:ir.model.fields,field_description:edi_upflow.field_res_company____last_update +#: model:ir.model.fields,field_description:edi_upflow.field_res_config_settings____last_update +#: model:ir.model.fields,field_description:edi_upflow.field_res_partner____last_update +#: model:ir.model.fields,field_description:edi_upflow.field_webservice_backend____last_update +msgid "Last Modified on" +msgstr "Dernière modification le" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__message_main_attachment_id +msgid "Main Attachment" +msgstr "Pièce jointe principale" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__message_has_error +msgid "Message Delivery error" +msgstr "Erreur d'envoi du message" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__message_ids +msgid "Messages" +msgstr "Messages" + +#. module: edi_upflow +#: code:addons/edi_upflow/components/base_upflow_edi_output_check.py:0 +#, python-format +msgid "" +"Not a valid HTTP error (expected 2xx, received %s) in order to processed the " +"payload (%s)" +msgstr "" +"Code HTTP non valide (attendu 2xx, reçu %s) pour permettre le traitement de " +"la réponse (%s)" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__message_needaction_counter +msgid "Number of Actions" +msgstr "Nombre d'actions" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__message_has_error_counter +msgid "Number of errors" +msgstr "Nombre d'erreurs" + +#. module: edi_upflow +#: model:ir.model.fields,help:edi_upflow.field_account_partial_reconcile__message_needaction_counter +msgid "Number of messages which requires an action" +msgstr "Nombre de messages exigeant une action" + +#. module: edi_upflow +#: model:ir.model.fields,help:edi_upflow.field_account_partial_reconcile__message_has_error_counter +msgid "Number of messages with delivery error" +msgstr "Nombre de messages avec des erreurs d'envoi" + +#. module: edi_upflow +#: model:ir.model.fields,help:edi_upflow.field_account_partial_reconcile__message_unread_counter +msgid "Number of unread messages" +msgstr "Nombre de messages non lus" + +#. module: edi_upflow +#: model:ir.model,name:edi_upflow.model_account_partial_reconcile +msgid "Partial Reconcile" +msgstr "Lettrage partiel" + +#. module: edi_upflow +#: model:ir.ui.menu,name:edi_upflow.menu_account_config +msgid "Partial reconcile" +msgstr "Lettrage partiel" + +#. module: edi_upflow +#: model_terms:ir.ui.view,arch_db:edi_upflow.account_partial_reconcile_form_view +msgid "Reconcile part" +msgstr "Lettrage partiel" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__message_has_sms_error +msgid "SMS Delivery error" +msgstr "Erreur d'envoi SMS" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__sent_to_upflow +msgid "Sent To Upflow" +msgstr "Envoyé à upflow" + +#. module: edi_upflow +#: model:ir.model.fields,help:edi_upflow.field_account_partial_reconcile__sent_to_upflow +msgid "Technical field to know if the record has been synchronized with upflow" +msgstr "" +"Champ technique permettant de savoir si l'enregistrement a été synchronisé " +"dans upflow" + +#. module: edi_upflow +#: model:ir.model.fields,help:edi_upflow.field_account_bank_statement_line__upflow_type +#: model:ir.model.fields,help:edi_upflow.field_account_move__upflow_type +#: model:ir.model.fields,help:edi_upflow.field_account_payment__upflow_type +msgid "" +"Technical fields to make sure consistency while sending Journal entry and " +"reconcile payloads. Key values are current payload keys while sending " +"reconcile. While creating malicious entries it can be hard to automatically " +"choose proper type" +msgstr "" + +#. module: edi_upflow +#: code:addons/edi_upflow/components/event_listener_account_partial_reconcile.py:0 +#, python-format +msgid "" +"This job has been canceled and its associated EDI exchange was deleted " +"because the associated reconciliation was deleted" +msgstr "" +"Cette tâche a été annulée et l'échange EDI associé a été supprimé car la " +"réconciliation associée a été supprimée" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__message_unread +msgid "Unread Messages" +msgstr "Messages non lus" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__message_unread_counter +msgid "Unread Messages Counter" +msgstr "Compteur de messages non lus" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_webservice_backend__upflow_api_key +msgid "Upflow API Key" +msgstr "Upflow API Key" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_webservice_backend__upflow_api_secret +msgid "Upflow API Secret" +msgstr "Upflow API Secret" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_res_partner__upflow_edi_backend_id +#: model:ir.model.fields,field_description:edi_upflow.field_res_users__upflow_edi_backend_id +msgid "Upflow Edi Backend" +msgstr "Upflow EDI Backend" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_bank_statement_line__upflow_type +#: model:ir.model.fields,field_description:edi_upflow.field_account_move__upflow_type +#: model:ir.model.fields,field_description:edi_upflow.field_account_payment__upflow_type +msgid "Upflow Type" +msgstr "Type upflow" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_res_company__upflow_backend_id +#: model:ir.model.fields,field_description:edi_upflow.field_res_config_settings__upflow_backend_id +msgid "Upflow backend" +msgstr "Upflow backend" + +#. module: edi_upflow +#: model_terms:ir.ui.view,arch_db:edi_upflow.res_config_settings_view_form +msgid "Upflow backend (on the current company)" +msgstr "Upflow backend (société du context actuel)" + +#. module: edi_upflow +#: model:ir.model.fields.selection,name:edi_upflow.selection__webservice_backend__auth_type__upflow +msgid "Upflow.io" +msgstr "Upflow.io" + +#. module: edi_upflow +#: model:ir.model,name:edi_upflow.model_webservice_backend +msgid "WebService Backend" +msgstr "" + +#. module: edi_upflow +#: model:ir.model.fields,field_description:edi_upflow.field_account_partial_reconcile__website_message_ids +msgid "Website Messages" +msgstr "Messages du site web" + +#. module: edi_upflow +#: model:ir.model.fields,help:edi_upflow.field_account_partial_reconcile__website_message_ids +msgid "Website communication history" +msgstr "Historique de communication du site" + +#. module: edi_upflow +#: model:ir.model.fields,help:edi_upflow.field_account_partial_reconcile__disable_edi_auto +#: model:ir.model.fields,help:edi_upflow.field_res_partner__disable_edi_auto +msgid "When marked, EDI automatic processing will be avoided" +msgstr "" + +#. module: edi_upflow +#: code:addons/edi_upflow/components/event_listener_account_partial_reconcile.py:0 +#, python-format +msgid "" +"You can reconcile journal items because the journal entry %s (ID: %s) is not " +"synchronisable with upflow.io, because partner is not set but required." +msgstr "" +"Vous ne pouvez pas lettrer ces écritures comptable car la pièce comptable %s " +"(ID: %s) n'est pas syncrhonisable avec upflow.io, car le partenaire est " +"requis." + +#. module: edi_upflow +#: code:addons/edi_upflow/components/base_upflow_edi_output_check.py:0 +#, python-format +msgid "" +"You should implement _check_and_process method to process upflow.io REST " +"response on this exchange type (code: %s)" +msgstr "" +"Vous devez implémenter la méthode _check_and_process method pour traiter la " +"réponse de L'API REST upflow.io pour le type ce type d'échange (code: %s)" + +#. module: edi_upflow +#: code:addons/edi_upflow/components/base_upflow_edi_output_generate.py:0 +#, python-format +msgid "" +"You should implement generate method to create the payload or fix the " +"exchange type. (Received exchange type code: %s)" +msgstr "" +"Vous devriez implémenter la méthode generate pour créer les données à " +"envoyer ou corriger le type d'échange (code d'échange reçu: %s)" diff --git a/edi_upflow/migrations/14.0.2.0.0/pre-migrate.py b/edi_upflow/migrations/14.0.2.0.0/pre-migrate.py new file mode 100644 index 000000000..7566aa813 --- /dev/null +++ b/edi_upflow/migrations/14.0.2.0.0/pre-migrate.py @@ -0,0 +1,16 @@ +from openupgradelib import openupgrade + + +@openupgrade.migrate() +def migrate(env, version): + if not version: + return + openupgrade.rename_xmlids( + env.cr, + [ + ( + "edi_upflow.account_move_form_view", + "edi_upflow.view_full_reconcile_form", + ), + ], + ) diff --git a/edi_upflow/models/__init__.py b/edi_upflow/models/__init__.py new file mode 100644 index 000000000..7cff0df8d --- /dev/null +++ b/edi_upflow/models/__init__.py @@ -0,0 +1,7 @@ +from . import account_move +from . import account_partial_reconcile +from . import edi_exchange_record +from . import webservice_backend +from . import res_partner +from . import res_company +from . import res_config_settings diff --git a/edi_upflow/models/account_move.py b/edi_upflow/models/account_move.py new file mode 100644 index 000000000..9e731eb27 --- /dev/null +++ b/edi_upflow/models/account_move.py @@ -0,0 +1,86 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models + + +class AccountMove(models.Model): + _inherit = "account.move" + + upflow_type = fields.Selection( + store=True, + copy=False, + required=True, + readonly=False, + default="none", + compute="_compute_upflow_type", + ) + """ + In some corner case and for historical reason we may force + upflow types likes migrating data from other system + """ + + def get_latest_upflow_exchange(self): + # we could probably inprove this a bit according state + # for the time being as exchange_record are sorted by + # `exchanged_on desc and id desc` this should gives + # the latest exchange + return fields.first( + self.exchange_record_ids.filtered( + lambda exchange: exchange.type_id.code + in [ + "upflow_post_invoice", + "upflow_post_credit_notes", + "upflow_post_payments", + "upflow_post_refunds", + ] + ) + ) + + @api.depends("state") + def _compute_upflow_type(self): + not_processed = self.browse() + for move in self: + if move.upflow_type != "none": + move.upflow_type = move.upflow_type + continue + latest_exchange = move.get_latest_upflow_exchange() + if latest_exchange: + move.upflow_type = self.mapping_exchange_upflow().get( + latest_exchange.type_id.code, "none" + ) + if move.upflow_type == "none": + not_processed |= move + super(AccountMove, not_processed)._compute_upflow_type() + + @api.model + def mapping_upflow_exchange(self): + """mappings between upflow types (using keys + of reconcile payload) and exchange types + """ + return { + "invoices": ( + "upflow_post_invoice", + "upflow_post_invoice_pdf", + ), + "payments": ( + "upflow_post_payments", + None, + ), + "creditNotes": ( + "upflow_post_credit_notes", + "upflow_post_credit_notes_pdf", + ), + "refunds": ( + "upflow_post_refunds", + None, + ), + } + + @api.model + def mapping_exchange_upflow(self): + mapping = {} + for key, value in self.mapping_upflow_exchange().items(): + mapping[value[0]] = key + return mapping diff --git a/edi_upflow/models/account_partial_reconcile.py b/edi_upflow/models/account_partial_reconcile.py new file mode 100644 index 000000000..db8dc9e96 --- /dev/null +++ b/edi_upflow/models/account_partial_reconcile.py @@ -0,0 +1,18 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class AccountPartialReconcile(models.Model): + _name = "account.partial.reconcile" + _inherit = [ + "account.partial.reconcile", + "mail.thread", + "edi.exchange.consumer.mixin", + ] + + sent_to_upflow = fields.Boolean( + default=False, + help="Technical field to know if the record has been synchronized with upflow", + ) diff --git a/edi_upflow/models/edi_exchange_record.py b/edi_upflow/models/edi_exchange_record.py new file mode 100644 index 000000000..6dbc99ebe --- /dev/null +++ b/edi_upflow/models/edi_exchange_record.py @@ -0,0 +1,20 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class EDIExchangeRecord(models.Model): + + _inherit = "edi.exchange.record" + + # TODO: considering to move that in edi_oca: + # https://github.com/OCA/edi/issues/782 + dependent_exchange_ids = fields.Many2many( + comodel_name="edi.exchange.record", + relation="edi_exchange_record_dependent_rel", + column1="exchange_id", + column2="dependent_exchange_id", + string="Depends on", + help="Following exchanges have to be processed before be able to run the current one.", + ) diff --git a/edi_upflow/models/res_company.py b/edi_upflow/models/res_company.py new file mode 100644 index 000000000..d98ee9ca4 --- /dev/null +++ b/edi_upflow/models/res_company.py @@ -0,0 +1,14 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + upflow_backend_id = fields.Many2one( + "edi.backend", + string="Upflow backend", + domain="[('backend_type_id.code', '=', 'upflow')]", + ) diff --git a/edi_upflow/models/res_config_settings.py b/edi_upflow/models/res_config_settings.py new file mode 100644 index 000000000..9c4f8768a --- /dev/null +++ b/edi_upflow/models/res_config_settings.py @@ -0,0 +1,12 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + upflow_backend_id = fields.Many2one( + related="company_id.upflow_backend_id", readonly=False, string="Upflow backend" + ) diff --git a/edi_upflow/models/res_partner.py b/edi_upflow/models/res_partner.py new file mode 100644 index 000000000..fc38f8ea9 --- /dev/null +++ b/edi_upflow/models/res_partner.py @@ -0,0 +1,81 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import api, fields, models + + +class ResPartner(models.Model): + _name = "res.partner" + _inherit = ["res.partner", "edi.exchange.consumer.mixin"] + + upflow_edi_backend_id = fields.Many2one( + "edi.backend", + help=( + "Backend used to synchronised this partner to upflow. " + "Technical field as while updating existing customer we are " + "not able to determine which upflow backend to use. " + "There are some limitation, a customer can't be synchronized " + "in two different backends. " + "A customer used by 2 different company (multi-company odoo feature) " + "manage in two upflow organisations is not support today. " + "So we direct link upflow backend here !" + ), + ) + + @api.model + def get_upflow_customer_fields(self): + """used in order to limit the number exchange record to + generate while communicate with upflow.io""" + # do not add upflow_uuid to avoid send exchange twice! + return [ + "name", + "ref", + "vat", + "street", + "street2", + "zip", + "city", + "state_id", + "country_id", + "child_ids", + ] + + @api.model + def get_upflow_customer_fields_to_update_contacts(self): + """used in order to limit the number exchange record to + generate while communicate with upflow.io + + @return a list of Many2one fields that needed to update contacts + """ + # do not add upflow_uuid to avoid send exchange twice! + return [ + "main_contact_id", + ] + + @api.model + def get_upflow_contact_fields_to_update_customer(self): + return ["parent_id", "commercial_partner_id"] + + @api.model + def get_upflow_contact_fields(self): + """used in order to limit the number exchange record to + generate while communicate with upflow.io""" + return [ + "name", + "mobile", + "email", + "upflow_position_id", + ] + + def write(self, vals): + """in case a contact change from one parent to an other we needs to synchronized both + + so emit an extra on_record_write on previous parent if set (before write) + """ + if "parent_id" in vals: + for rec in self: + if rec.parent_id: + self._event("on_record_write").notify( + rec.parent_id, fields=["child_ids"] + ) + return super().write(vals) diff --git a/edi_upflow/models/webservice_backend.py b/edi_upflow/models/webservice_backend.py new file mode 100644 index 000000000..91d73580c --- /dev/null +++ b/edi_upflow/models/webservice_backend.py @@ -0,0 +1,29 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class WebserviceBackend(models.Model): + + _inherit = "webservice.backend" + + upflow_api_key = fields.Char(string="Upflow API Key", auth_type="upflow") + upflow_api_secret = fields.Char(string="Upflow API Secret", auth_type="upflow") + auth_type = fields.Selection( + selection_add=[("upflow", "Upflow.io")], + ondelete={ + "upflow": lambda recs: recs.write({"auth_type": "none"}), + }, + ) + + @property + def _server_env_fields(self): + fields = super()._server_env_fields + fields.update( + { + "upflow_api_key": {}, + "upflow_api_secret": {}, + } + ) + return fields diff --git a/edi_upflow/readme/CONFIGURATION.rst b/edi_upflow/readme/CONFIGURATION.rst new file mode 100644 index 000000000..374916ece --- /dev/null +++ b/edi_upflow/readme/CONFIGURATION.rst @@ -0,0 +1,14 @@ +On each company you want to synchronised with upflow you +needs in Accounting configuration set the EDI Upflow backend to be used +on the customer invoicing section. + +You should create a different backend if you have different upflow organisation +otherwise you can reuse the same backend. + +If you create different backend mind to duplicate exchange type and link them to +the newly created backend. + +Each backend are linked to a webservice configuration where you can setup +the ufpflow HTTP API credentials. + +Upflow credentials are linked to the upflwo organisation. diff --git a/edi_upflow/readme/CONTRIBUTORS.rst b/edi_upflow/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..ac99e52a8 --- /dev/null +++ b/edi_upflow/readme/CONTRIBUTORS.rst @@ -0,0 +1,5 @@ +* `Foodles `_ + + * Pierre Verkest + * Alexandre Galdeano + * Matthias BARKAT diff --git a/edi_upflow/readme/DESCRIPTION.rst b/edi_upflow/readme/DESCRIPTION.rst new file mode 100644 index 000000000..97680e3b1 --- /dev/null +++ b/edi_upflow/readme/DESCRIPTION.rst @@ -0,0 +1,39 @@ + +Based on OCA EDI Frameworks this modules aims to integrate +Odoo and Upflow.io. + +# Trigger + +Here is the list of event that generate exchange that push data to upflow: + +* When account entry of the following type are posted + out invoice / out refund / invoice payment or refund payment, then + the customer will be synchronised if no ufpflow id present in the database. + +* When full reconcile line is created it will create missing account entry if any + (backend statement reconcile can works without account payment in odoo) and send reconcile + info to upflow. + +* any change on synchronized information to a customer or a contact will update all customers + informations to upflow. + + +# Multi-company + +A customer and sales accounting entries are linked to only one backend. + +Backend are linked to a company, but once a customer has been synchronised +for a given backend (first sale accounting entry), next accounting entries +will be linked to the same backend (upflow organisation) what ever the current +backend set on the current company. + +# Asynchrone tasks + +Data are send asynchronously, according your configuration tasks can take few minutes +to be handle and send to upflow. + +On each relevent form views you will see an EDI smart button +that let you check the state of the reletated exchange synchronizations. + +This module is based on EDI Frameworks maintain by OCA which depends on the `queue_job` +module you should also monitor queue job tasks. diff --git a/edi_upflow/readme/ROADMAP.rst b/edi_upflow/readme/ROADMAP.rst new file mode 100644 index 000000000..e69de29bb diff --git a/edi_upflow/static/description/index.html b/edi_upflow/static/description/index.html new file mode 100644 index 000000000..cb82c244d --- /dev/null +++ b/edi_upflow/static/description/index.html @@ -0,0 +1,462 @@ + + + + + +EDI UPFLOW + + + +
+

EDI UPFLOW

+ + +

Alpha License: AGPL-3 OCA/credit-control Translate me on Weblate Try me on Runboat

+

Based on OCA EDI Frameworks this modules aims to integrate +Odoo and Upflow.io.

+

# Trigger

+

Here is the list of event that generate exchange that push data to upflow:

+
    +
  • When account entry of the following type are posted +out invoice / out refund / invoice payment or refund payment, then +the customer will be synchronised if no ufpflow id present in the database.
  • +
  • When full reconcile line is created it will create missing account entry if any +(backend statement reconcile can works without account payment in odoo) and send reconcile +info to upflow.
  • +
  • any change on synchronized information to a customer or a contact will update all customers +informations to upflow.
  • +
+

# Multi-company

+

A customer and sales accounting entries are linked to only one backend.

+

Backend are linked to a company, but once a customer has been synchronised +for a given backend (first sale accounting entry), next accounting entries +will be linked to the same backend (upflow organisation) what ever the current +backend set on the current company.

+

# Asynchrone tasks

+

Data are send asynchronously, according your configuration tasks can take few minutes +to be handle and send to upflow.

+

On each relevent form views you will see an EDI smart button +that let you check the state of the reletated exchange synchronizations.

+

This module is based on EDI Frameworks maintain by OCA which depends on the queue_job +module you should also monitor queue job tasks.

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

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

+
    +
  • Pierre Verkest
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

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

+

Current maintainer:

+

petrus-v

+

This module is part of the OCA/credit-control project on GitHub.

+

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

+
+
+
+ + diff --git a/edi_upflow/tests/__init__.py b/edi_upflow/tests/__init__.py new file mode 100644 index 000000000..5659e1036 --- /dev/null +++ b/edi_upflow/tests/__init__.py @@ -0,0 +1,3 @@ +from . import test_edi_upflow +from . import test_edi_upflow_error +from . import test_multi_company_backend diff --git a/edi_upflow/tests/common.py b/edi_upflow/tests/common.py new file mode 100644 index 000000000..86c4f3d03 --- /dev/null +++ b/edi_upflow/tests/common.py @@ -0,0 +1,61 @@ +from contextlib import contextmanager +from unittest import mock + +from odoo.addons.base_upflow.tests.common import AccountingCommonCase +from odoo.addons.component.tests.common import SavepointComponentRegistryCase + + +class EDIUpflowCommonCase(SavepointComponentRegistryCase, AccountingCommonCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._setup_env() + cls._load_module_components(cls, "component_event") + cls._load_module_components(cls, "edi_oca") + cls._load_module_components(cls, "edi_account_oca") + cls._load_module_components(cls, "edi_upflow") + cls._setup_records() + + @contextmanager + def _consumer_record_no_new_env(record, new_cursor=True): + yield record + + patcher_consumer_record_same_transaction = mock.patch( + "odoo.addons.webservice.models.webservice_backend." + "WebserviceBackend._consumer_record_env", + side_effect=_consumer_record_no_new_env, + ) + cls.addClassCleanup(patcher_consumer_record_same_transaction.stop) + patcher_consumer_record_same_transaction.start() + + @classmethod + def _setup_context(cls): + return dict( + cls.env.context, tracking_disable=True, test_queue_job_no_delay=False + ) + + @classmethod + def _setup_env(cls): + cls.env = cls.env(context=cls._setup_context()) + + @classmethod + def _setup_records(cls): + cls.backend = cls._get_backend() + cls.env.company.upflow_backend_id = cls.backend + cls.upflow_ws = cls.backend.webservice_backend_id + cls.partner = cls.env.ref("base.res_partner_1") + cls.partner.ref = "EDI_EXC_TEST" + cls.partner.commercial_partner_id.vat = "FR13542107651" + cls._setup_accounting() + + @classmethod + def _get_backend(cls): + return cls.env.ref("edi_upflow.upflow_edi_backend") + + +class EDIUpflowCommonCaseRunningJob(EDIUpflowCommonCase): + @classmethod + def _setup_context(cls): + return dict( + cls.env.context, tracking_disable=True, test_queue_job_no_delay=True + ) diff --git a/edi_upflow/tests/test_edi_upflow.py b/edi_upflow/tests/test_edi_upflow.py new file mode 100644 index 000000000..fe2374703 --- /dev/null +++ b/edi_upflow/tests/test_edi_upflow.py @@ -0,0 +1,2513 @@ +import json +from collections import defaultdict +from unittest import mock +from uuid import uuid4 + +import responses + +from odoo.exceptions import UserError +from odoo.tests.common import tagged +from odoo.tools import mute_logger + +from odoo.addons.component.core import Component +from odoo.addons.edi_upflow.components.edi_output_check_upflow_post_customers import ( + _logger as check_upflow_post_customer_logger, +) +from odoo.addons.queue_job.exception import RetryableJobError +from odoo.addons.queue_job.tests.common import trap_jobs + +from .common import EDIUpflowCommonCase, EDIUpflowCommonCaseRunningJob + + +@tagged("post_install", "-at_install") +class TestEDIUpflowHeader(EDIUpflowCommonCase): + @classmethod + def _setup_records(cls): + super()._setup_records() + cls.invoice = cls._create_invoice(auto_validate=False) + + @responses.activate + def test_upflow_auth_type_and_generated_header(self): + record = self.backend.create_record( + "upflow_post_invoice", + { + "model": self.invoice._name, + "res_id": self.invoice.id, + }, + ) + record.write({"edi_exchange_state": "output_pending"}) + record._set_file_content("TEST %d" % record.id) + + url = self.upflow_ws.url.format(endpoint="v1/invoices") + expected_key = "UPFLOW API KEY TEST" + expected_secret = "UPFLOW API KEY SECRET" + self.upflow_ws.write( + { + "auth_type": "upflow", + "upflow_api_key": expected_key, + "upflow_api_secret": expected_secret, + } + ) + response_result = "{}" + responses.add(responses.POST, url, body=response_result) + record.action_exchange_send() + headers = responses.calls[0].request.headers + self.assertEqual(headers["X-Api-Key"], expected_key) + self.assertEqual(headers["X-Api-Secret"], expected_secret) + + +@tagged("post_install", "-at_install") +class TestEDIUpflowBaseClasses(EDIUpflowCommonCase): + @classmethod + def _setup_records(cls): + super()._setup_records() + cls.exchange_type = cls.env["edi.exchange.type"].create( + { + "name": "Test post something", + "code": "upflow_post_something", + "backend_id": cls.env.ref("edi_upflow.upflow_edi_backend").id, + "backend_type_id": cls.env.ref("edi_upflow.upflow_edi_backend_type").id, + "direction": "output", + "exchange_file_auto_generate": False, + "advanced_settings_edit": """ + components: + send: + usage: webservice.send + webservice: + method: post + kwargs: + url_params: + endpoint: v1/something + """, + } + ) + cls.env["edi.exchange.type.rule"].create( + { + "name": "Default Test post something rule", + "model_id": cls.env.ref("account.model_account_move").id, + "type_id": cls.exchange_type.id, + "kind": "form_btn", + } + ) + cls.invoice = cls._create_invoice(auto_validate=False) + + def test_generate_not_implemented(self): + class EdiOutputGenerateUpflowPostSomething(Component): + _name = "edi.output.generate.upflow_post_something" + _inherit = "base.upflow.edi.output.generate" + _exchange_type = "upflow_post_something" + + EdiOutputGenerateUpflowPostSomething._build_component(self.comp_registry) + self.comp_registry._cache.clear() + + record = self.backend.create_record( + "upflow_post_something", + { + "model": self.invoice._name, + "res_id": self.invoice.id, + }, + ) + with self.assertRaisesRegex( + NotImplementedError, + r"You should implement generate method " + r"to create the payload or fix the exchange type. " + r"\(Received exchange type code: upflow_post_something\)", + ): + record.action_exchange_generate() + + def test_check_not_implemented(self): + class EdiOutputCheckUpflowPostSomething(Component): + _name = "edi.output.check.upflow_post_something" + _inherit = "base.upflow.edi.output.check" + _exchange_type = "upflow_post_something" + + EdiOutputCheckUpflowPostSomething._build_component(self.comp_registry) + self.comp_registry._cache.clear() + + record = self.backend.create_record( + "upflow_post_something", + { + "model": self.invoice._name, + "res_id": self.invoice.id, + }, + ) + self.backend._exchange_output_check_state(record) + self.assertEqual( + record.edi_exchange_state, + "output_sent_and_error", + ) + self.assertTrue( + "NotImplementedError" in record.exchange_error, + f"NotImplementedError not found in current exchange error: {record.exchange_error}", + ) + self.assertTrue( + "on this exchange type (code: upflow_post_something)" + in record.exchange_error, + f"current exchange error: {record.exchange_error}", + ) + + def test_upflow_check_check_ws_response_status_code_raises(self): + class EdiOutputCheckUpflowPostSomething(Component): + _name = "edi.output.check.upflow_post_something" + _inherit = "base.upflow.edi.output.check" + _exchange_type = "upflow_post_something" + + def _check_and_process(self): + self._upflow_check_and_process() + + EdiOutputCheckUpflowPostSomething._build_component(self.comp_registry) + self.comp_registry._cache.clear() + + record = self.backend.create_record( + "upflow_post_something", + { + "model": self.invoice._name, + "res_id": self.invoice.id, + }, + ) + record.ws_response_status_code = 199 + response = json.dumps("TEST") + record._set_file_content(response, field_name="ws_response_content") + self.backend._exchange_output_check_state(record) + + self.assertEqual( + record.edi_exchange_state, + "output_sent_and_error", + ) + self.assertTrue( + "ValidationError" in record.exchange_error, + f"ValidationError not found in current exchange error {record.exchange_error}", + ) + self.assertTrue( + "Not a valid HTTP error (expected 2xx, received 199)" + in record.exchange_error, + f"Received exchange error: {record.exchange_error}", + ) + self.assertTrue( + f"the payload ({response})" in record.exchange_error, + f"Received exchange error: {record.exchange_error}", + ) + record.edi_exchange_state = "output_sent" + record.ws_response_status_code = 300 + self.backend._exchange_output_check_state(record) + self.assertEqual( + record.edi_exchange_state, + "output_sent_and_error", + ) + self.assertTrue( + "ValidationError" in record.exchange_error, + f"ValidationError not found in current exchange error {record.exchange_error}", + ) + self.assertTrue( + "Not a valid HTTP error (expected 2xx, received 300)" + in record.exchange_error, + f"Received exchange error: {record.exchange_error}", + ) + self.assertTrue( + f"the payload ({response})" in record.exchange_error, + f"Received exchange error: {record.exchange_error}", + ) + + def test_upflow_check_check_ws_response_missing_id(self): + class EdiOutputCheckUpflowPostSomething(Component): + _name = "edi.output.check.upflow_post_something" + _inherit = "base.upflow.edi.output.check" + _exchange_type = "upflow_post_something" + + def _check_and_process(self): + self._upflow_check_and_process() + + EdiOutputCheckUpflowPostSomething._build_component(self.comp_registry) + self.comp_registry._cache.clear() + record = self.backend.create_record( + "upflow_post_something", + { + "model": self.invoice._name, + "res_id": self.invoice.id, + }, + ) + + record._set_file_content(json.dumps({}), field_name="ws_response_content") + record.ws_response_status_code = 200 + + self.backend._exchange_output_check_state(record) + self.assertEqual( + record.edi_exchange_state, + "output_sent_and_error", + ) + self.assertTrue( + "KeyError" in record.exchange_error, + f"KeyError not found in current exchange error: {record.exchange_error}", + ) + self.assertTrue( + "id" in record.exchange_error, + f"id not found in current exchange error: {record.exchange_error}", + ) + + def test_upflow_check_check_ws_response_no_payload(self): + class EdiOutputCheckUpflowPostSomething(Component): + _name = "edi.output.check.upflow_post_something" + _inherit = "base.upflow.edi.output.check" + _exchange_type = "upflow_post_something" + + def _check_and_process(self): + self._upflow_check_and_process() + + EdiOutputCheckUpflowPostSomething._build_component(self.comp_registry) + self.comp_registry._cache.clear() + record = self.backend.create_record( + "upflow_post_something", + { + "model": self.invoice._name, + "res_id": self.invoice.id, + }, + ) + + record.ws_response_status_code = 200 + + self.backend._exchange_output_check_state(record) + self.assertEqual( + record.edi_exchange_state, + "output_sent_and_error", + ) + self.assertTrue( + "KeyError" in record.exchange_error, + f"KeyError not found in current exchange error: {record.exchange_error}", + ) + self.assertTrue( + "id" in record.exchange_error, + f"id not found in current exchange error: {record.exchange_error}", + ) + + +@tagged("post_install", "-at_install") +class TestFlows(EDIUpflowCommonCaseRunningJob): + @classmethod + def _setup_records(cls): + super()._setup_records() + + type_current_liability = cls.env.ref( + "account.data_account_type_current_liabilities" + ) + cls.final_output_vat_acct = cls.env["account.account"].create( + { + "name": "final vat account", + "code": "FINAL-20", + "reconcile": True, + "user_type_id": type_current_liability.id, + } + ) + cls.transition_vat_acct = cls.env["account.account"].create( + { + "name": "waiting vat account", + "code": "WAIT-20", + "reconcile": True, + "user_type_id": type_current_liability.id, + } + ) + cls.tax_group_vat = cls.env["account.tax.group"].create({"name": "VAT"}) + + cls.vat_on_payment = cls.env["account.tax"].create( + { + "name": "Test 20% on payment", + "type_tax_use": "sale", + "amount_type": "percent", + "amount": 20.00, + "tax_exigibility": "on_payment", + "tax_group_id": cls.tax_group_vat.id, + "cash_basis_transition_account_id": cls.transition_vat_acct.id, + "invoice_repartition_line_ids": [ + (0, 0, {"factor_percent": 100.0, "repartition_type": "base"}), + ( + 0, + 0, + { + "factor_percent": 100.0, + "repartition_type": "tax", + "account_id": cls.final_output_vat_acct.id, + }, + ), + ], + } + ) + cls.invoice = cls._create_invoice( + auto_validate=False, vat_ids=cls.vat_on_payment.ids + ) + cls.refund = cls._create_invoice( + move_type="out_refund", auto_validate=False, vat_ids=cls.vat_on_payment.ids + ) + + @mute_logger("odoo.addons.queue_job.delay") + @responses.activate + def test_output_exchange_sync_post_invoice_flow(self): + generated_content = '{"some": "value"}' + invoice_uuid = str(uuid4()) + response_result = json.dumps( + { + "externalId": "FAC123", + "issuedAt": "2015-05-05T12:30:00", + "dueDate": "2015-05-05T12:30:00", + "name": "Facture couvrant les prestations de service de Decembre", + "currency": "EUR", + "grossAmount": 1700, + "netAmount": 1500, + "id": invoice_uuid, + "customerId": "a1b2c3", + "payments": [ + { + "amount": 1700, + "executedAt": "2015-05-05T12:30:00", + "instrument": "'WIRE_TRANSFER'", + } + ], + "pdfUrl": "http://example.com/invoice.pdf", + "state": "DUE", + } + ) + + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint="v1/customers"), + body=json.dumps({"id": str(uuid4())}), + ) + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint="v1/invoices"), + body=response_result, + ) + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint=f"v1/invoices/{invoice_uuid}/pdf"), + body="", + status=204, + ) + with mock.patch( + "odoo.addons.edi_upflow.components" + ".edi_output_generate_upflow_post_invoice" + ".EdiOutputGenerateUpflowPostInvoice.generate", + return_value=generated_content, + ) as m_generate: + self.invoice.action_post() + m_generate.assert_called_once() + + self.assertEqual(len(responses.calls), 3) + self.assertEqual( + [call for call in responses.calls if call.request.url.endswith("invoices")][ + 0 + ].request.body, + generated_content, + ) + records = self.env["edi.exchange.record"].search( + [ + ("model", "=", "account.move"), + ("res_id", "=", self.invoice.id), + ( + "type_id", + "=", + self.env.ref( + "edi_upflow.upflow_edi_exchange_type_post_invoices" + ).id, + ), + ] + ) + self.assertEqual(len(records), 1) + self.assertEqual(records.edi_exchange_state, "output_sent_and_processed") + self.assertEqual(self.partner.upflow_edi_backend_id, self.backend) + + @mute_logger("odoo.addons.queue_job.delay") + @responses.activate + def test_output_exchange_sync_post_invoice_pdf_flow(self): + generated_content = '{"some": "value"}' + invoice_uuid = str(uuid4()) + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint="v1/customers"), + body=json.dumps({"id": str(uuid4())}), + ) + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint="v1/invoices"), + body=json.dumps( + { + "id": invoice_uuid, + } + ), + ) + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint=f"v1/invoices/{invoice_uuid}/pdf"), + body="", + status=204, + ) + with mock.patch( + "odoo.addons.edi_upflow.components" + ".edi_output_generate_upflow_post_invoice_pdf" + ".EdiOutputGenerateUpflowPostInvoicePDF.generate", + return_value=generated_content, + ) as m_generate: + self.invoice.action_post() + m_generate.assert_called_once() + + self.assertEqual(len(responses.calls), 3) + self.assertEqual( + [ + call + for call in responses.calls + if call.request.url.endswith(f"{invoice_uuid}/pdf") + ][0].request.body, + generated_content, + ) + records = self.env["edi.exchange.record"].search( + [ + ("model", "=", "account.move"), + ("res_id", "=", self.invoice.id), + ( + "type_id", + "=", + self.env.ref( + "edi_upflow.upflow_edi_exchange_type_post_invoices_pdf" + ).id, + ), + ] + ) + self.assertEqual(len(records), 1) + self.assertEqual(records.edi_exchange_state, "output_sent_and_processed") + + @mute_logger("odoo.addons.queue_job.delay") + @responses.activate + def test_output_exchange_sync_post_credit_notes_flow(self): + generated_content = '{"some": "value"}' + credit_note_uuid = str(uuid4()) + response_result = json.dumps( + { + "customId": "FAC123", + "externalId": "92842AB37", + "issuedAt": "2015-05-05T12:30:00", + "dueDate": "2015-05-05T12:30:00", + "name": "Facture couvrant les prestations de service de Decembre", + "currency": "EUR", + "grossAmount": 1700, + "netAmount": 1500, + "id": credit_note_uuid, + "pdfUrl": "http://example.com/invoice.pdf", + "customer": { + "id": "00a70b35-2be3-4c43-aefb-397190134655", + "externalId": "1a2c3b", + "companyName": "Upflow SAS", + "accountingRef": "UPFL", + }, + "linkedInvoices": [], + } + ) + + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint="v1/customers"), + body=json.dumps({"id": str(uuid4())}), + ) + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint="v1/credit_notes"), + body=response_result, + ) + responses.add( + responses.POST, + self.upflow_ws.url.format( + endpoint=f"v1/credit_notes/{credit_note_uuid}/pdf" + ), + body="", + status=204, + ) + with mock.patch( + "odoo.addons.edi_upflow.components" + ".edi_output_generate_upflow_post_credit_notes" + ".EdiOutputGenerateUpflowPostCreditNotes.generate", + return_value=generated_content, + ) as m_generate: + self.refund.action_post() + m_generate.assert_called_once() + + self.assertEqual(len(responses.calls), 3) + self.assertEqual( + [ + call + for call in responses.calls + if call.request.url.endswith("credit_notes") + ][0].request.body, + generated_content, + ) + records = self.env["edi.exchange.record"].search( + [ + ("model", "=", "account.move"), + ("res_id", "=", self.refund.id), + ( + "type_id", + "=", + self.env.ref( + "edi_upflow.upflow_edi_exchange_type_post_credit_notes" + ).id, + ), + ] + ) + self.assertEqual(len(records), 1) + self.assertEqual(records.edi_exchange_state, "output_sent_and_processed") + + @mute_logger("odoo.addons.queue_job.delay") + @responses.activate + def test_output_exchange_sync_post_credit_notes_as_payment_flow(self): + """Testing forcing upflow type""" + generated_content = '{"some": "value"}' + credit_note_uuid = str(uuid4()) + response_result = json.dumps( + { + "customId": "FAC123", + "externalId": "92842AB37", + "issuedAt": "2015-05-05T12:30:00", + "dueDate": "2015-05-05T12:30:00", + "name": "Facture couvrant les prestations de service de Decembre", + "currency": "EUR", + "grossAmount": 1700, + "netAmount": 1500, + "id": credit_note_uuid, + "pdfUrl": "http://example.com/invoice.pdf", + "customer": { + "id": "00a70b35-2be3-4c43-aefb-397190134655", + "externalId": "1a2c3b", + "companyName": "Upflow SAS", + "accountingRef": "UPFL", + }, + "linkedInvoices": [], + } + ) + + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint="v1/customers"), + body=json.dumps({"id": str(uuid4())}), + ) + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint="v1/payments"), + body=response_result, + ) + self.refund.upflow_type = "payments" + with mock.patch( + "odoo.addons.edi_upflow.components" + ".edi_output_generate_upflow_post_payments" + ".EdiOutputGenerateUpflowPostPayments.generate", + return_value=generated_content, + ) as m_generate: + self.refund.action_post() + m_generate.assert_called_once() + + self.assertEqual(len(responses.calls), 2) + self.assertEqual( + [call for call in responses.calls if call.request.url.endswith("payments")][ + 0 + ].request.body, + generated_content, + ) + records = self.env["edi.exchange.record"].search( + [ + ("model", "=", "account.move"), + ("res_id", "=", self.refund.id), + ( + "type_id", + "=", + self.env.ref( + "edi_upflow.upflow_edi_exchange_type_post_credit_notes" + ).id, + ), + ] + ) + self.assertEqual(len(records), 0) + records = self.env["edi.exchange.record"].search( + [ + ("model", "=", "account.move"), + ("res_id", "=", self.refund.id), + ( + "type_id", + "=", + self.env.ref( + "edi_upflow.upflow_edi_exchange_type_post_payments" + ).id, + ), + ] + ) + self.assertEqual(len(records), 1) + + self.refund.upflow_type = "none" + self.refund._compute_upflow_type() + self.assertEqual(self.refund.upflow_type, "payments") + self.assertEqual(records.edi_exchange_state, "output_sent_and_processed") + + @mute_logger("odoo.addons.queue_job.delay") + @responses.activate + def test_output_exchange_sync_post_credit_notes_flow_pdf(self): + generated_content = '{"some": "value"}' + credit_note_uuid = str(uuid4()) + + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint="v1/customers"), + body=json.dumps({"id": str(uuid4())}), + ) + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint="v1/credit_notes"), + body=json.dumps( + { + "id": credit_note_uuid, + } + ), + ) + responses.add( + responses.POST, + self.upflow_ws.url.format( + endpoint=f"v1/credit_notes/{credit_note_uuid}/pdf" + ), + body=json.dumps( + { + "id": credit_note_uuid, + } + ), + status=204, + ) + with mock.patch( + "odoo.addons.edi_upflow.components" + ".edi_output_generate_upflow_post_credit_notes_pdf" + ".EdiOutputGenerateUpflowPostCreditNotesPDF.generate", + return_value=generated_content, + ) as m_generate: + self.refund.action_post() + m_generate.assert_called_once() + + self.assertEqual(len(responses.calls), 3) + self.assertEqual( + [ + call + for call in responses.calls + if call.request.url.endswith(f"credit_notes/{credit_note_uuid}/pdf") + ][0].request.body, + generated_content, + ) + records = self.env["edi.exchange.record"].search( + [ + ("model", "=", "account.move"), + ("res_id", "=", self.refund.id), + ( + "type_id", + "=", + self.env.ref( + "edi_upflow.upflow_edi_exchange_type_post_credit_notes_pdf" + ).id, + ), + ] + ) + self.assertEqual(len(records), 1) + self.assertEqual(records.edi_exchange_state, "output_sent_and_processed") + + @mute_logger("odoo.addons.queue_job.delay") + @responses.activate + def test_output_exchange_sync_post_payments_flow(self): + generated_content = '{"some": "value"}' + response_result = json.dumps( + { + "currency": "EUR", + "amount": 1700, + "instrument": "CARD", + "validatedAt": "2015-05-05T12:30:00", + "externalId": "92842AB37", + "bankAccountId": "00a70b35-2be3-4c43-aefb-397190134655", + "id": "00a70b35-2be3-4c43-aefb-397190134655", + "type": "ACCOUNT", + "amountLinked": 1700, + "modifiedAt": "2015-05-05T12:30:00", + "linkedInvoices": [ + { + "linkedAmount": 3000, + "invoice": { + "id": "00a70b35-2be3-4c43-aefb-397190134655", + "currency": "EUR", + "status": "DUE", + "amountOutstanding": 1500, + "customId": "FAC123", + "grossAmount": 1700, + "netAmount": 1500, + "name": "Facture couvrant les prestations de service de Decembre", + "issuedAt": "2015-05-05T12:30:00", + "dueDate": "2015-05-05T12:30:00", + "paidDate": "2015-05-05T12:30:00", + "externalId": "92842AB37", + }, + } + ], + "customer": { + "id": "00a70b35-2be3-4c43-aefb-397190134655", + "externalId": "1a2c3b", + "companyName": "Upflow SAS", + "accountingRef": "UPFL", + }, + } + ) + invoice_uuid = uuid4() + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint="v1/customers"), + body=json.dumps({"id": str(uuid4())}), + ) + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint="v1/invoices"), + body=json.dumps({"id": str(invoice_uuid)}), + ) + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint=f"v1/invoices/{invoice_uuid}/pdf"), + body="", + status=204, + ) + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint="v1/payments"), + body=response_result, + ) + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint="v1/reconcile"), + body="CREATED", + ) + with mock.patch( + "odoo.addons.edi_upflow.components" + ".edi_output_generate_upflow_post_payments" + ".EdiOutputGenerateUpflowPostPayments.generate", + return_value=generated_content, + ) as m_generate: + self.invoice.action_post() + move = self._register_manual_payment_reconciled(self.invoice) + m_generate.assert_called_once() + + self.assertEqual(len(responses.calls), 5) + self.assertEqual( + [call for call in responses.calls if call.request.url.endswith("payments")][ + 0 + ].request.body, + generated_content, + ) + records = self.env["edi.exchange.record"].search( + [ + ("model", "=", "account.move"), + ("res_id", "=", move.id), + ( + "type_id", + "=", + self.env.ref( + "edi_upflow.upflow_edi_exchange_type_post_payments" + ).id, + ), + ] + ) + self.assertEqual(len(records), 1) + self.assertEqual(records.edi_exchange_state, "output_sent_and_processed") + + def test_post_payments_without_partner_id(self): + with mock.patch( + "odoo.addons.edi_upflow.components" + ".edi_output_generate_upflow_post_payments" + ".EdiOutputGenerateUpflowPostPayments.generate", + ) as m_generate, mock.patch( + "odoo.addons.edi_upflow.components.event_listener_base.log" + ) as m_log: + bank_journal, method, payment_date, amount, currency = self._payment_params( + self.invoice, + ) + payment = self.env["account.payment"].create( + { + "payment_type": "inbound", + "partner_type": "customer", + "journal_id": bank_journal.id, + "payment_method_id": method.id, + "amount": amount, + "currency_id": currency.id, + "date": payment_date, + } + ) + payment.action_post() + m_generate.assert_not_called() + m_log.warning.assert_called_once_with( + "No Upflow commercial partner found for move %s, ignoring", + payment.move_id, + ) + + @mute_logger("odoo.addons.queue_job.delay") + @responses.activate + def test_output_exchange_sync_post_refunds_flow(self): + generated_content = '{"some": "value"}' + response_result = json.dumps( + { + "currency": "EUR", + "amount": 1700, + "instrument": "CARD", + "validatedAt": "2015-05-05T12:30:00", + "externalId": "92842AB37", + "id": "00a70b35-2be3-4c43-aefb-397190134655", + "amountLinked": 1700, + "modifiedAt": "2015-05-05T12:30:00", + "linkedTransactions": [], + "linkedCreditNotes": [], + "customer": { + "id": "00a70b35-2be3-4c43-aefb-397190134655", + "externalId": "1a2c3b", + "companyName": "Upflow SAS", + "accountingRef": "UPFL", + }, + } + ) + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint="v1/customers"), + body=json.dumps({"id": str(uuid4())}), + ) + credit_note_uuid = str(uuid4()) + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint="v1/credit_notes"), + body=json.dumps({"id": credit_note_uuid}), + ) + responses.add( + responses.POST, + self.upflow_ws.url.format( + endpoint=f"v1/credit_notes/{credit_note_uuid}/pdf" + ), + body=json.dumps( + { + "id": credit_note_uuid, + } + ), + status=204, + ) + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint="v1/refunds"), + body=response_result, + ) + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint="v1/reconcile"), + body="CREATED", + ) + with mock.patch( + "odoo.addons.edi_upflow.components" + ".edi_output_generate_upflow_post_refunds" + ".EdiOutputGenerateUpflowPostRefunds.generate", + return_value=generated_content, + ) as m_generate: + self.refund.action_post() + move = self._register_manual_payment_reconciled(self.refund) + m_generate.assert_called_once() + + self.assertEqual(len(responses.calls), 5) + self.assertEqual( + [call for call in responses.calls if call.request.url.endswith("refunds")][ + 0 + ].request.body, + generated_content, + ) + records = self.env["edi.exchange.record"].search( + [ + ("model", "=", "account.move"), + ("res_id", "=", move.id), + ( + "type_id", + "=", + self.env.ref("edi_upflow.upflow_edi_exchange_type_post_refunds").id, + ), + ] + ) + self.assertEqual(len(records), 1) + self.assertEqual(records.edi_exchange_state, "output_sent_and_processed") + + @mute_logger("odoo.addons.queue_job.delay") + @responses.activate + def test_output_exchange_sync_post_reconcile_flow(self): + generated_content = '{"some": "value"}' + response_result = json.dumps("CREATED") + + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint="v1/customers"), + body=json.dumps({"id": str(uuid4())}), + ) + invoice_uuid = str(uuid4()) + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint="v1/invoices"), + body=json.dumps({"id": invoice_uuid}), + ) + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint=f"v1/invoices/{invoice_uuid}/pdf"), + body="", + status=204, + ) + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint="v1/payments"), + body=json.dumps({"id": str(uuid4())}), + ) + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint="v1/reconcile"), + body=response_result, + ) + with mock.patch( + "odoo.addons.edi_upflow.components" + ".edi_output_generate_upflow_post_reconcile" + ".EdiOutputGenerateUpflowPostReconcile.generate", + return_value=generated_content, + ) as m_generate: + self.invoice.action_post() + self._register_manual_payment_reconciled(self.invoice) + # Depending of test database this is useless as it done by payment + # force to reconcile vat amounts while using temporary account + # to manage on payment VAT + self.env["account.move.line"].search( + [ + ("account_id", "=", self.transition_vat_acct.id), + ("parent_state", "=", "posted"), + ("full_reconcile_id", "=", False), + ] + ).reconcile() + partial_reconcile = self.invoice.line_ids.matched_credit_ids + m_generate.assert_called_once() + + self.assertEqual(len(responses.calls), 5) + self.assertEqual( + [ + call + for call in responses.calls + if call.request.url.endswith("reconcile") + ][0].request.body, + generated_content, + ) + records = self.env["edi.exchange.record"].search( + [ + ("model", "=", partial_reconcile._name), + ("res_id", "in", partial_reconcile.ids), + ( + "type_id", + "=", + self.env.ref( + "edi_upflow.upflow_edi_exchange_type_post_reconcile" + ).id, + ), + ] + ) + self.assertEqual(len(records), 1) + self.assertEqual(records.edi_exchange_state, "output_sent_and_processed") + + @mute_logger("odoo.addons.queue_job.delay") + @responses.activate + def test_output_exchange_unlink_reconcile_flow(self): + response_result = json.dumps("CREATED") + + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint="v1/customers"), + body=json.dumps({"id": str(uuid4())}), + ) + invoice_uuid = str(uuid4()) + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint="v1/invoices"), + body=json.dumps({"id": invoice_uuid}), + ) + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint=f"v1/invoices/{invoice_uuid}/pdf"), + body="", + status=204, + ) + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint="v1/payments"), + body=json.dumps({"id": str(uuid4())}), + ) + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint="v1/reconcile"), + body=response_result, + ) + self.invoice.action_post() + self._register_manual_payment_reconciled(self.invoice) + partial_reconcile = self.invoice.line_ids.matched_credit_ids + partial_reconcile.unlink() + + self.assertEqual(len(responses.calls), 6) + reconcile_calls = [ + call for call in responses.calls if call.request.url.endswith("reconcile") + ] + self.assertEqual( + len(json.loads(reconcile_calls[0].request.body)["invoices"]), 1 + ) + self.assertEqual( + len(json.loads(reconcile_calls[1].request.body)["invoices"]), 0 + ) + records = self.env["edi.exchange.record"].search( + [ + ("model", "=", partial_reconcile._name), + ("res_id", "in", partial_reconcile.ids), + ( + "type_id", + "=", + self.env.ref( + "edi_upflow.upflow_edi_exchange_type_post_reconcile" + ).id, + ), + ] + ) + self.assertEqual(len(records), 2) + + @mute_logger("odoo.addons.queue_job.delay") + @responses.activate + def test_output_exchange_sync_post_reconcile_with_refund_flow(self): + def group_recordset_by(recordset, key): + groups = defaultdict(self.env[recordset._name].browse) + for elem in recordset: + groups[key(elem)] |= elem + return groups.items() + + generated_content = '{"some": "value"}' + response_result = json.dumps("CREATED") + + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint="v1/customers"), + body=json.dumps({"id": str(uuid4())}), + ) + invoice_uuid = str(uuid4()) + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint="v1/invoices"), + body=json.dumps({"id": invoice_uuid}), + ) + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint=f"v1/invoices/{invoice_uuid}/pdf"), + body="", + status=204, + ) + + credit_note_uuid = str(uuid4()) + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint="v1/credit_notes"), + body=json.dumps({"id": credit_note_uuid}), + ) + responses.add( + responses.POST, + self.upflow_ws.url.format( + endpoint=f"v1/credit_notes/{credit_note_uuid}/pdf" + ), + body=json.dumps( + { + "id": credit_note_uuid, + } + ), + status=204, + ) + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint="v1/reconcile"), + body=response_result, + ) + with mock.patch( + "odoo.addons.edi_upflow.components" + ".edi_output_generate_upflow_post_reconcile" + ".EdiOutputGenerateUpflowPostReconcile.generate", + return_value=generated_content, + ) as m_generate: + self.invoice.action_post() + self.refund.action_post() + for _key, group in group_recordset_by( + (self.invoice.line_ids | self.refund.line_ids).filtered( + lambda line: line.account_id.reconcile + ), + lambda l: l.account_id.user_type_id.type, + ): + group.reconcile() + partial_reconcile = self.invoice.line_ids.matched_credit_ids + m_generate.assert_called_once() + + self.assertEqual(len(responses.calls), 6) + self.assertEqual( + [ + call + for call in responses.calls + if call.request.url.endswith("reconcile") + ][0].request.body, + generated_content, + ) + records = self.env["edi.exchange.record"].search( + [ + ("model", "=", partial_reconcile._name), + ("res_id", "in", partial_reconcile.ids), + ( + "type_id", + "=", + self.env.ref( + "edi_upflow.upflow_edi_exchange_type_post_reconcile" + ).id, + ), + ] + ) + self.assertEqual(len(records), 1) + self.assertEqual(records.edi_exchange_state, "output_sent_and_processed") + + @mute_logger("odoo.addons.queue_job.delay") + @responses.activate + def test_output_exchange_sync_post_customers_flow(self): + generated_content = '{"some": "value"}' + response_result = json.dumps( + { + "name": "Upflow SAS", + "vatNumber": "838718328", + "accountingRef": "UPFL", + "externalId": "1a2c3b", + "accountManagerId": "00a70b35-2be3-4c43-aefb-397190134655", + "dunningPlanId": "7a6c91dc-3580-4c43-aefb-397190134655", + "address": { + "address": "25 Passage Dubail", + "zipcode": "75010", + "city": "Paris", + "state": "ÃŽle", + "country": "France", + }, + "parent": { + "id": "00a70b35-2be3-4c43-aefb-397190134655", + "externalId": "1a2c3b", + }, + "paymentMethods": { + "card": {"enabled": False}, + "check": {"enabled": False}, + "achDebit": {"enabled": False}, + "sepaDebit": {"enabled": False}, + "goCardless": {"enabled": False}, + "wireTransfer": { + "enabled": False, + "bankAccount": {"id": "00a70b35-2be3-4c43-aefb-397190134655"}, + "bankAccounts": [ + {"id": "00a70b35-2be3-4c43-aefb-397190134655"} + ], + }, + }, + "customFields": [ + { + "externalId": "AEGaaZD", + "id": "00a70b35-2be3-4c43-aefb-397190134655", + "value": None, + "source": "USER_DEFINED", + } + ], + "id": "00a70b35-2be3-4c43-aefb-397190134655", + "countInvoicesDue": 0, + "countInvoicesOverdue": 1, + "amountDue": 0, + "amountOverdue": 118000, + "currency": "Currency", + "directUrl": "https://app.upflow.io/customers/ABCDEFGHI", + } + ) + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint="v1/customers"), + body=response_result, + ) + invoice_uuid = str(uuid4()) + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint="v1/invoices"), + body=json.dumps({"id": invoice_uuid}), + ) + responses.add( + responses.POST, + self.upflow_ws.url.format(endpoint=f"v1/invoices/{invoice_uuid}/pdf"), + body="", + status=204, + ) + with mock.patch( + "odoo.addons.edi_upflow.components" + ".edi_output_generate_upflow_post_customers" + ".EdiOutputGenerateUpflowPostCustomers.generate", + return_value=generated_content, + ) as m_generate: + self.invoice.action_post() + # self.backend._cron_check_output_exchange_sync(skip_sent=False) + m_generate.assert_called_once() + + self.assertEqual(len(responses.calls), 3) + self.assertEqual( + [ + call + for call in responses.calls + if call.request.url.endswith("customers") + ][0].request.body, + generated_content, + ) + records = self.env["edi.exchange.record"].search( + [ + ("model", "=", "res.partner"), + ("res_id", "=", self.invoice.commercial_partner_id.id), + ( + "type_id", + "=", + self.env.ref( + "edi_upflow.upflow_edi_exchange_type_upflow_post_customers" + ).id, + ), + ] + ) + self.assertEqual(len(records), 1) + self.assertEqual(records.edi_exchange_state, "output_sent_and_processed") + + +@tagged("post_install", "-at_install") +class TestEDIUpflowInvoices(EDIUpflowCommonCase): + """Invoices flows POST v1/invoices""" + + @classmethod + def _setup_records(cls): + super()._setup_records() + cls.invoice = cls._create_invoice(auto_validate=False) + + def test_post_invoice_create_exchange_record(self): + domain = [ + ("model", "=", "account.move"), + ("res_id", "=", self.invoice.id), + ( + "type_id", + "=", + self.env.ref("edi_upflow.upflow_edi_exchange_type_post_invoices").id, + ), + ] + records = self.env["edi.exchange.record"].search(domain) + self.assertEqual(len(records), 0) + + with trap_jobs() as trap: + self.invoice.action_post() + # 1 customer + 1 invoice + 1 PDF => 3 jobs + trap.assert_jobs_count(3) + records = self.env["edi.exchange.record"].search(domain) + self.assertEqual(len(records), 1) + self.assertEqual(records.edi_exchange_state, "new") + + def test_post_invoice_on_synchronized_partner_create_exchange_record(self): + self.partner.upflow_uuid = str(uuid4()) + self.partner.upflow_edi_backend_id = self.backend + domain = [ + ("model", "=", "account.move"), + ("res_id", "=", self.invoice.id), + ] + records = self.env["edi.exchange.record"].search(domain) + self.assertEqual(len(records), 0) + + with trap_jobs() as trap: + self.invoice.action_post() + # 1 invoice + 1 PDF => 2 jobs + trap.assert_jobs_count(2) + self.assertEqual( + self.env["edi.exchange.record"].search_count( + domain + + [ + ( + "type_id", + "=", + self.env.ref( + "edi_upflow.upflow_edi_exchange_type_post_invoices" + ).id, + ), + ] + ), + 1, + ) + self.assertEqual( + self.env["edi.exchange.record"].search_count( + domain + + [ + ( + "type_id", + "=", + self.env.ref( + "edi_upflow.upflow_edi_exchange_type_post_invoices_pdf" + ).id, + ), + ] + ), + 1, + ) + self.assertEqual( + self.env["edi.exchange.record"].search_count( + domain + + [ + ( + "type_id", + "=", + self.env.ref( + "edi_upflow.upflow_edi_exchange_type_upflow_post_customers" + ).id, + ), + ] + ), + 0, + ) + + def test_perform_invoice_job_before_customer_job_is_send_and_processed(self): + with trap_jobs() as trap: + self.invoice.action_post() + # 1 customer + 1 invoice + 1 invoice PDF => 3 jobs + trap.assert_jobs_count(3) + account_move_job = [ + job + for job in trap.enqueued_jobs + if job.recordset.res_id == self.invoice.id + and job.recordset.model == "account.move" + ][0] + with self.assertRaisesRegex( + RetryableJobError, "Waiting related exchanges to be done before" + ): + account_move_job.perform() + + def test_upflow_post_invoice_generate(self): + record = self.backend.create_record( + "upflow_post_invoice", + { + "model": self.invoice._name, + "res_id": self.invoice.id, + }, + ) + record.action_exchange_generate() + self.assertTrue( + record._get_file_content(), + ) + + def test_upflow_post_invoice_match_generate(self): + record = self.backend.create_record( + "upflow_post_invoice", + { + "model": self.invoice._name, + "res_id": self.invoice.id, + }, + ) + generated_content = '{"some": "value"}' + with mock.patch( + "odoo.addons.edi_upflow.components" + ".edi_output_generate_upflow_post_invoice" + ".EdiOutputGenerateUpflowPostInvoice.generate", + return_value=generated_content, + ) as m_generate: + record.action_exchange_generate() + m_generate.assert_called_once() + self.assertEqual(record._get_file_content(), generated_content) + + def test_upflow_post_invoice_check(self): + record = self.backend.create_record( + "upflow_post_invoice", + { + "model": self.invoice._name, + "res_id": self.invoice.id, + }, + ) + uuid = str(uuid4()) + record._set_file_content( + json.dumps({"id": uuid}), field_name="ws_response_content" + ) + record.ws_response_status_code = 200 + self.backend._exchange_output_check_state(record) + self.assertEqual(record.edi_exchange_state, "output_sent_and_processed") + self.assertEqual(self.invoice.upflow_uuid, uuid) + + +@tagged("post_install", "-at_install") +class TestEDIUpflowInvoicesPDF(EDIUpflowCommonCase): + """Invoices flows POST v1/invoices//pdf""" + + @classmethod + def _setup_records(cls): + super()._setup_records() + cls.invoice = cls._create_invoice(auto_validate=False) + + def test_post_invoice_pdf_create_exchange_record(self): + domain = [ + ("model", "=", "account.move"), + ("res_id", "=", self.invoice.id), + ( + "type_id", + "=", + self.env.ref( + "edi_upflow.upflow_edi_exchange_type_post_invoices_pdf" + ).id, + ), + ] + records = self.env["edi.exchange.record"].search(domain) + self.assertEqual(len(records), 0) + + with trap_jobs() as trap: + self.invoice.action_post() + # 1 customer + 1 invoice + 1 invoice pdf => 3 jobs + trap.assert_jobs_count(3) + records = self.env["edi.exchange.record"].search(domain) + self.assertEqual(len(records), 1) + self.assertEqual(records.edi_exchange_state, "new") + + def test_upflow_post_invoice_pdf_generate(self): + record = self.backend.create_record( + "upflow_post_invoice_pdf", + { + "model": self.invoice._name, + "res_id": self.invoice.id, + }, + ) + record.action_exchange_generate() + self.assertTrue( + record._get_file_content(), + ) + + def test_upflow_post_invoice_pdf_match_generate(self): + record = self.backend.create_record( + "upflow_post_invoice_pdf", + { + "model": self.invoice._name, + "res_id": self.invoice.id, + }, + ) + generated_content = '{"some": "value"}' + with mock.patch( + "odoo.addons.edi_upflow.components" + ".edi_output_generate_upflow_post_invoice_pdf" + ".EdiOutputGenerateUpflowPostInvoicePDF.generate", + return_value=generated_content, + ) as m_generate: + record.action_exchange_generate() + m_generate.assert_called_once() + self.assertEqual(record._get_file_content(), generated_content) + + def test_upflow_post_invoice_pdf_check(self): + record = self.backend.create_record( + "upflow_post_invoice_pdf", + { + "model": self.invoice._name, + "res_id": self.invoice.id, + }, + ) + # real end point do not return uuid + # so here we ensure we would not erase data if any founds + uuid = str(uuid4()) + record._set_file_content( + json.dumps({"id": uuid}), field_name="ws_response_content" + ) + record.ws_response_status_code = 200 + self.backend._exchange_output_check_state(record) + self.assertEqual(record.edi_exchange_state, "output_sent_and_processed") + self.assertFalse(self.invoice.upflow_uuid) + + +@tagged("post_install", "-at_install") +class TestEDIUpflowCreditNotes(EDIUpflowCommonCase): + """Credit notes flows POST v1/credit_notes""" + + @classmethod + def _setup_records(cls): + super()._setup_records() + cls.refund = cls._create_invoice(move_type="out_refund", auto_validate=False) + + def test_post_refund_create_exchange_record(self): + domain = [ + ("model", "=", "account.move"), + ("res_id", "=", self.refund.id), + ( + "type_id", + "=", + self.env.ref( + "edi_upflow.upflow_edi_exchange_type_post_credit_notes" + ).id, + ), + ] + records = self.env["edi.exchange.record"].search(domain) + self.assertEqual(len(records), 0) + with trap_jobs() as trap: + self.refund.action_post() + # 1 customer + 1 refund + 1pdf => 2 jobs + trap.assert_jobs_count(3) + records = self.env["edi.exchange.record"].search(domain) + self.assertEqual(len(records), 1) + self.assertEqual(records.edi_exchange_state, "new") + + def test_upflow_post_refund_generate(self): + record = self.backend.create_record( + "upflow_post_credit_notes", + { + "model": self.refund._name, + "res_id": self.refund.id, + }, + ) + record.action_exchange_generate() + self.assertTrue( + record._get_file_content(), + ) + + def test_upflow_post_credit_notes_match_generate(self): + record = self.backend.create_record( + "upflow_post_credit_notes", + { + "model": self.refund._name, + "res_id": self.refund.id, + }, + ) + generated_content = '{"some": "value"}' + with mock.patch( + "odoo.addons.edi_upflow.components" + ".edi_output_generate_upflow_post_credit_notes" + ".EdiOutputGenerateUpflowPostCreditNotes.generate", + return_value=generated_content, + ) as m_generate: + record.action_exchange_generate() + m_generate.assert_called_once() + self.assertEqual(record._get_file_content(), generated_content) + + def test_upflow_post_credit_notes_check(self): + record = self.backend.create_record( + "upflow_post_credit_notes", + { + "model": self.refund._name, + "res_id": self.refund.id, + }, + ) + uuid = str(uuid4()) + record._set_file_content( + json.dumps({"id": uuid}), field_name="ws_response_content" + ) + record.ws_response_status_code = 200 + self.backend._exchange_output_check_state(record) + self.assertEqual(record.edi_exchange_state, "output_sent_and_processed") + self.assertEqual(self.refund.upflow_uuid, uuid) + + +@tagged("post_install", "-at_install") +class TestEDIUpflowInvoicePayment(EDIUpflowCommonCase): + """Credit notes flows POST v1/credit_notes""" + + @classmethod + def _setup_records(cls): + super()._setup_records() + cls.invoice = cls._create_invoice(auto_validate=True) + + def test_post_payment_create_exchange_record(self): + domain = [ + ("model", "=", "account.move"), + ( + "type_id", + "=", + self.env.ref("edi_upflow.upflow_edi_exchange_type_post_payments").id, + ), + ] + records = self.env["edi.exchange.record"].search(domain) + self.assertEqual(len(records), 0) + + with trap_jobs() as trap: + self._register_manual_payment_reconciled(self.invoice) + trap.assert_jobs_count(3) + + records = self.env["edi.exchange.record"].search(domain) + self.assertEqual(len(records), 1) + self.assertEqual(records.edi_exchange_state, "new") + + def test_upflow_post_payments_generate(self): + move = self._register_manual_payment_reconciled(self.invoice) + record = self.backend.create_record( + "upflow_post_payments", + { + "model": move._name, + "res_id": move.id, + }, + ) + record.action_exchange_generate() + self.assertTrue( + record._get_file_content(), + ) + + def test_upflow_post_payments_match_generate(self): + move = self._register_manual_payment_reconciled(self.invoice) + record = self.backend.create_record( + "upflow_post_payments", + { + "model": move._name, + "res_id": move.id, + }, + ) + generated_content = '{"some": "value"}' + with mock.patch( + "odoo.addons.edi_upflow.components" + ".edi_output_generate_upflow_post_payments" + ".EdiOutputGenerateUpflowPostPayments.generate", + return_value=generated_content, + ) as m_generate: + record.action_exchange_generate() + m_generate.assert_called_once() + self.assertEqual(record._get_file_content(), generated_content) + + def test_upflow_post_payments_check(self): + move = self._register_manual_payment_reconciled(self.invoice) + record = self.backend.create_record( + "upflow_post_payments", + { + "model": move._name, + "res_id": move.id, + }, + ) + uuid = str(uuid4()) + record._set_file_content( + json.dumps({"id": uuid}), field_name="ws_response_content" + ) + record.ws_response_status_code = 200 + self.backend._exchange_output_check_state(record) + self.assertEqual(record.edi_exchange_state, "output_sent_and_processed") + self.assertEqual(move.upflow_uuid, uuid) + + +@tagged("post_install", "-at_install") +class TestEDIUpflowRefunds(EDIUpflowCommonCase): + """flows POST v1/credit_notes""" + + @classmethod + def _setup_records(cls): + super()._setup_records() + cls.refund = cls._create_invoice( + move_type="out_refund", + auto_validate=True, + ) + + def test_post_refund_payment_create_exchange_record(self): + domain = [ + ("model", "=", "account.move"), + ( + "type_id", + "=", + self.env.ref("edi_upflow.upflow_edi_exchange_type_post_refunds").id, + ), + ] + records = self.env["edi.exchange.record"].search(domain) + self.assertEqual(len(records), 0) + with trap_jobs() as trap: + self._register_manual_payment_reconciled(self.refund) + trap.assert_jobs_count(3) + records = self.env["edi.exchange.record"].search(domain) + self.assertEqual(len(records), 1) + self.assertEqual(records.edi_exchange_state, "new") + + def test_upflow_post_refunds_generate(self): + move = self._register_manual_payment_reconciled(self.refund) + record = self.backend.create_record( + "upflow_post_refunds", + { + "model": move._name, + "res_id": move.id, + }, + ) + record.action_exchange_generate() + self.assertTrue( + record._get_file_content(), + ) + + def test_upflow_post_refunds_match_generate(self): + move = self._register_manual_payment_reconciled(self.refund) + record = self.backend.create_record( + "upflow_post_refunds", + { + "model": move._name, + "res_id": move.id, + }, + ) + generated_content = '{"some": "value"}' + with mock.patch( + "odoo.addons.edi_upflow.components" + ".edi_output_generate_upflow_post_refunds" + ".EdiOutputGenerateUpflowPostRefunds.generate", + return_value=generated_content, + ) as m_generate: + record.action_exchange_generate() + m_generate.assert_called_once() + self.assertEqual(record._get_file_content(), generated_content) + + def test_upflow_post_refunds_check(self): + move = self._register_manual_payment_reconciled(self.refund) + record = self.backend.create_record( + "upflow_post_refunds", + { + "model": move._name, + "res_id": move.id, + }, + ) + uuid = str(uuid4()) + record._set_file_content( + json.dumps({"id": uuid}), field_name="ws_response_content" + ) + record.ws_response_status_code = 200 + self.backend._exchange_output_check_state(record) + self.assertEqual(record.edi_exchange_state, "output_sent_and_processed") + self.assertEqual(move.upflow_uuid, uuid) + + +@tagged("post_install", "-at_install") +class TestEdiUpflowReconcileOperation(EDIUpflowCommonCase): + @classmethod + def _setup_records(cls): + super()._setup_records() + cls.invoice = cls._create_invoice(auto_validate=True) + + def test_on_create_account_partial_reconcile_create_exchange_record(self): + domain = [ + ("model", "=", "account.partial.reconcile"), + ( + "type_id", + "=", + self.env.ref("edi_upflow.upflow_edi_exchange_type_post_reconcile").id, + ), + ] + self.assertEqual(self.env["edi.exchange.record"].search_count(domain), 0) + with trap_jobs() as trap: + self._register_manual_payment_reconciled(self.invoice) + trap.assert_jobs_count(3) + records = self.env["edi.exchange.record"].search(domain) + self.assertEqual(len(records), 1) + self.assertEqual(records.edi_exchange_state, "new") + + def test_on_create_payed_supplier_invoice_should_not_create_any_exchanges(self): + domain = [] + count_before = self.env["edi.exchange.record"].search_count(domain) + with trap_jobs() as trap: + supplier_invoice = self._create_invoice( + move_type="in_invoice", auto_validate=True + ) + self._register_manual_payment_reconciled( + supplier_invoice, + payment_type="outbound", + bank_journal=None, + method=self.env.ref("account.account_payment_method_manual_out"), + ) + trap.assert_jobs_count(0) + self.assertEqual( + self.env["edi.exchange.record"].search_count(domain), count_before + ) + + def test_on_create_reconcile_without_payment_create_exchange_record_and_payment_record( + self, + ): + reconcile_domain = [ + ("model", "=", "account.partial.reconcile"), + ( + "type_id", + "=", + self.env.ref("edi_upflow.upflow_edi_exchange_type_post_reconcile").id, + ), + ] + + payments_domain = [ + ("model", "=", "account.move"), + ( + "type_id", + "=", + self.env.ref("edi_upflow.upflow_edi_exchange_type_post_payments").id, + ), + ] + self.assertEqual( + self.env["edi.exchange.record"].search_count(reconcile_domain), 0 + ) + self.assertEqual( + self.env["edi.exchange.record"].search_count(payments_domain), 0 + ) + self.invoice.commercial_partner_id.upflow_uuid = "abc" + with trap_jobs() as trap: + self._make_credit_transfer_payment_reconciled( + self.invoice, + reconcile_param=[ + { + "id": self.invoice.line_ids.filtered( + lambda line: line.account_internal_type + in ("receivable", "payable") + ).id + } + ], + ) + trap.assert_jobs_count(2) + reconcile_exchange = self.env["edi.exchange.record"].search(reconcile_domain) + self.assertEqual(len(reconcile_exchange), 1) + self.assertEqual(reconcile_exchange.edi_exchange_state, "new") + payment_exchange = self.env["edi.exchange.record"].search(payments_domain) + self.assertEqual(len(payment_exchange), 1) + self.assertEqual(payment_exchange.edi_exchange_state, "new") + # test related exchanges + invoice_exchange = self.env["edi.exchange.record"].search( + [ + ("model", "=", "account.move"), + ( + "type_id", + "=", + self.env.ref( + "edi_upflow.upflow_edi_exchange_type_post_invoices" + ).id, + ), + ] + ) + invoice_pdf_exchange = self.env["edi.exchange.record"].search( + [ + ("model", "=", "account.move"), + ( + "type_id", + "=", + self.env.ref( + "edi_upflow.upflow_edi_exchange_type_post_invoices_pdf" + ).id, + ), + ] + ) + customer_exchange = self.env["edi.exchange.record"].search( + [ + ("model", "=", "res.partner"), + ( + "type_id", + "=", + self.env.ref( + "edi_upflow.upflow_edi_exchange_type_upflow_post_customers" + ).id, + ), + ] + ) + self.assertEqual(invoice_exchange.dependent_exchange_ids, (customer_exchange)) + self.assertEqual( + invoice_pdf_exchange.dependent_exchange_ids, (invoice_exchange) + ) + self.assertEqual( + reconcile_exchange.dependent_exchange_ids, + (payment_exchange | invoice_exchange), + ) + + def test_on_create_account_full_reconcile_without_payment_raise_on_reconcile( + self, + ): + reconcile_domain = [ + ("model", "=", "account.partial.reconcile"), + ( + "type_id", + "=", + self.env.ref("edi_upflow.upflow_edi_exchange_type_post_reconcile").id, + ), + ] + + payments_domain = [ + ("model", "=", "account.move"), + ( + "type_id", + "=", + self.env.ref("edi_upflow.upflow_edi_exchange_type_post_payments").id, + ), + ] + self.assertEqual( + self.env["edi.exchange.record"].search_count(reconcile_domain), 0 + ) + self.assertEqual( + self.env["edi.exchange.record"].search_count(payments_domain), 0 + ) + self.invoice.commercial_partner_id.upflow_uuid = "abc" + with trap_jobs() as trap: + self._make_credit_transfer_payment_reconciled( + self.invoice, + reconcile_param=[ + { + "id": self.invoice.line_ids.filtered( + lambda line: line.account_internal_type + in ("receivable", "payable") + ).id + } + ], + partner=False, + ) + trap.assert_jobs_count(2) + self.assertEqual( + self.env["edi.exchange.record"].search_count(reconcile_domain), 1 + ) + self.assertEqual( + self.env["edi.exchange.record"].search_count(payments_domain), 1 + ) + + def test_create_missing_exchange_record_without_partner(self): + move_payment_id = self._make_credit_transfer_payment_reconciled( + self.invoice, + reconcile_param=[ + { + "id": self.invoice.line_ids.filtered( + lambda line: line.account_internal_type + in ("receivable", "payable") + ).id + } + ], + partner=False, + ) + move_payment_id.line_ids.partner_id = False + move_payment_id._compute_upflow_commercial_partner_id() + with self.assertRaisesRegex( + UserError, + "You can reconcile journal items because the journal entry .* " + "is not synchronisable with upflow.io, because partner is not " + "set but required.", + ): + self.comp_registry["account.partial.reconcile.upflow.event.listener"]( + None + )._create_missing_exchange_record( + self.env["edi.exchange.record"].browse(), move_payment_id + ) + + def test_upflow_post_reconcile(self): + self._register_manual_payment_reconciled(self.invoice) + partial_reconcile = self.invoice.line_ids.matched_credit_ids + record = self.backend.create_record( + "upflow_post_reconcile", + { + "model": partial_reconcile._name, + "res_id": partial_reconcile.id, + }, + ) + record.action_exchange_generate() + self.assertTrue( + record._get_file_content(), + ) + + def test_upflow_post_reconcile_match_generate(self): + self._register_manual_payment_reconciled(self.invoice) + partial_reconcile = self.invoice.line_ids.matched_credit_ids + record = self.backend.create_record( + "upflow_post_reconcile", + { + "model": partial_reconcile._name, + "res_id": partial_reconcile.id, + }, + ) + generated_content = '{"some": "value"}' + with mock.patch( + "odoo.addons.edi_upflow.components" + ".edi_output_generate_upflow_post_reconcile" + ".EdiOutputGenerateUpflowPostReconcile.generate", + return_value=generated_content, + ) as m_generate: + record.action_exchange_generate() + m_generate.assert_called_once() + self.assertEqual(record._get_file_content(), generated_content) + + def test_upflow_post_reconcile_check(self): + self._register_manual_payment_reconciled(self.invoice) + partial_reconcile = self.invoice.line_ids.matched_credit_ids + exchange_record = self.backend.create_record( + "upflow_post_reconcile", + { + "model": partial_reconcile._name, + "res_id": partial_reconcile.id, + }, + ) + exchange_record._set_file_content( + json.dumps("CREATED"), field_name="ws_response_content" + ) + exchange_record.ws_response_status_code = 200 + self.backend._exchange_output_check_state(exchange_record) + self.assertEqual( + exchange_record.edi_exchange_state, "output_sent_and_processed" + ) + self.assertTrue(exchange_record.record.sent_to_upflow) + + def test_unlink_reconcile_delete_exchanges_and_cancel_jobs(self): + self._register_manual_payment_reconciled(self.invoice) + + exchange_record = self.env["edi.exchange.record"].search( + [ + ("model", "=", "account.partial.reconcile"), + ( + "type_id", + "=", + self.env.ref( + "edi_upflow.upflow_edi_exchange_type_post_reconcile" + ).id, + ), + ] + ) + self.assertEqual(len(exchange_record), 1) + self.assertEqual(exchange_record.edi_exchange_state, "new") + queue_job = ( + self.env["queue.job"] + .search([]) + .filtered(lambda queue_job: queue_job.records.id == exchange_record.id) + ) + self.assertEqual(len(queue_job), 1) + self.assertEqual(queue_job.state, "pending") + + with ( + mock.patch("odoo.addons.mail.models.mail_thread.MailThread.message_post") + ) as mock_message_post: + exchange_record.record.unlink() + + self.assertFalse(exchange_record.exists()) + self.assertEqual(queue_job.state, "cancelled") + mock_message_post.assert_called_once_with( + body="This job has been canceled and its associated EDI exchange was deleted " + "because the associated reconciliation was deleted", + message_type="comment", + subtype_xmlid="mail.mt_note", + author_id=self.env.ref("base.partner_root").id, + ) + + +@tagged("post_install", "-at_install") +class TestEDIUpflowCustomersContacts(EDIUpflowCommonCase): + """Invoices flows POST v1/invoices""" + + @classmethod + def _setup_records(cls): + super()._setup_records() + cls.invoice = cls._create_invoice(auto_validate=False) + cls.partner.upflow_edi_backend_id = cls.backend + + def test_post_invoice_create_exchange_record(self): + domain = [ + ("model", "=", "res.partner"), + ("res_id", "=", self.invoice.commercial_partner_id.id), + ( + "type_id", + "=", + self.env.ref( + "edi_upflow.upflow_edi_exchange_type_upflow_post_customers" + ).id, + ), + ] + records = self.env["edi.exchange.record"].search(domain) + self.assertEqual(len(records), 0) + + with trap_jobs() as trap: + self.invoice.action_post() + trap.assert_jobs_count(3) + + records = self.env["edi.exchange.record"].search(domain) + self.assertEqual(len(records), 1) + self.assertEqual(records.edi_exchange_state, "new") + + def test_update_synchronized_customer_create_exchange_record(self): + self.partner.upflow_uuid = str(uuid4()) + + domain = [ + ("model", "=", "res.partner"), + ("res_id", "=", self.partner.id), + ( + "type_id", + "=", + self.env.ref( + "edi_upflow.upflow_edi_exchange_type_upflow_post_customers" + ).id, + ), + ] + records = self.env["edi.exchange.record"].search(domain) + self.assertEqual(len(records), 0) + + with trap_jobs() as trap: + self.partner.street = "abcd" + trap.assert_jobs_count(1) + + records = self.env["edi.exchange.record"].search(domain) + self.assertEqual(len(records), 1) + self.assertEqual(records.edi_exchange_state, "new") + + def test_update_other_field_on_synchronized_customer_do_not_create_exchange_record( + self, + ): + self.partner.upflow_uuid = str(uuid4()) + + domain = [ + ("model", "=", "res.partner"), + ("res_id", "=", self.partner.id), + ( + "type_id", + "=", + self.env.ref( + "edi_upflow.upflow_edi_exchange_type_upflow_post_customers" + ).id, + ), + ] + self.assertEqual(self.env["edi.exchange.record"].search_count(domain), 0) + + with trap_jobs() as trap: + self.partner.phone = "101" + trap.assert_jobs_count(0) + + self.assertEqual(self.env["edi.exchange.record"].search_count(domain), 0) + + def test_add_contact_to_synchronized_customer_create_exchange_record(self): + self.partner.upflow_uuid = str(uuid4()) + + domain = [ + ("model", "=", "res.partner"), + ("res_id", "=", self.partner.id), + ( + "type_id", + "=", + self.env.ref( + "edi_upflow.upflow_edi_exchange_type_upflow_post_customers" + ).id, + ), + ] + records = self.env["edi.exchange.record"].search(domain) + self.assertEqual(len(records), 0) + + with trap_jobs() as trap: + self.env["res.partner"].create( + {"name": "test", "parent_id": self.partner.id} + ) + trap.assert_jobs_count(1) + + records = self.env["edi.exchange.record"].search(domain) + self.assertEqual(len(records), 1) + self.assertEqual(records.edi_exchange_state, "new") + + def test_unlink_contact_to_synchronized_customer_create_exchange_record(self): + contact = self.env["res.partner"].create( + {"name": "test", "parent_id": self.partner.id} + ) + self.partner.upflow_uuid = str(uuid4()) + + domain = [ + ("model", "=", "res.partner"), + ("res_id", "=", self.partner.id), + ( + "type_id", + "=", + self.env.ref( + "edi_upflow.upflow_edi_exchange_type_upflow_post_customers" + ).id, + ), + ] + records = self.env["edi.exchange.record"].search(domain) + self.assertEqual(len(records), 0) + + with trap_jobs() as trap: + contact.unlink() + trap.assert_jobs_count(1) + + records = self.env["edi.exchange.record"].search(domain) + self.assertEqual(len(records), 1) + self.assertEqual(records.edi_exchange_state, "new") + + def test_change_parent_contact_to_synchronized_customer_create_exchange_record( + self, + ): + partner2 = self.env["res.partner"].create({"name": "test 2"}) + contact = self.env["res.partner"].create( + {"name": "test", "parent_id": self.partner.id} + ) + partner2.upflow_uuid = str(uuid4()) + partner2.upflow_edi_backend_id = self.backend + self.partner.upflow_uuid = str(uuid4()) + + domain = [ + ("model", "=", "res.partner"), + ( + "type_id", + "=", + self.env.ref( + "edi_upflow.upflow_edi_exchange_type_upflow_post_customers" + ).id, + ), + ] + records = self.env["edi.exchange.record"].search(domain) + self.assertEqual(len(records), 0) + + with trap_jobs() as trap: + contact.parent_id = partner2 + trap.assert_jobs_count(2) + + records = self.env["edi.exchange.record"].search(domain) + self.assertEqual(len(records), 2) + exchange_partners = [r.record for r in records] + self.assertTrue(partner2 in exchange_partners) + self.assertTrue(self.partner in exchange_partners) + + def test_change_customer_main_contact_id_generate_one_edi_call_on_contact( + self, + ): + contact = self.env["res.partner"].create( + {"name": "test", "parent_id": self.partner.id} + ) + self.partner.upflow_uuid = str(uuid4()) + + domain = [ + ("model", "=", "res.partner"), + ( + "type_id", + "=", + self.env.ref( + "edi_upflow.upflow_edi_exchange_type_upflow_put_contacts" + ).id, + ), + ] + records = self.env["edi.exchange.record"].search(domain) + self.assertEqual(len(records), 0) + + with trap_jobs() as trap: + self.partner.main_contact_id = contact.id + trap.assert_jobs_count(1) + + records = self.env["edi.exchange.record"].search(domain) + self.assertEqual(len(records), 1) + exchange_partners = [r.record for r in records] + self.assertTrue(contact in exchange_partners) + + def test_change_contacts_field_generate_one_edi_call_on_contact(self): + contact = self.env["res.partner"].create( + {"name": "test", "parent_id": self.partner.id} + ) + contact.upflow_uuid = str(uuid4()) + self.partner.upflow_uuid = str(uuid4()) + + domain = [ + ("model", "=", "res.partner"), + ( + "type_id", + "=", + self.env.ref( + "edi_upflow.upflow_edi_exchange_type_upflow_put_contacts" + ).id, + ), + ] + records = self.env["edi.exchange.record"].search(domain) + self.assertEqual(len(records), 0) + with trap_jobs() as trap: + contact.name = "test 2" + trap.assert_jobs_count(1) + + records = self.env["edi.exchange.record"].search(domain) + self.assertEqual(len(records), 1) + exchange_partners = [r.record for r in records] + self.assertTrue(contact in exchange_partners) + + def test_change_contacts_field_without_upflow_uuid_generate_one_edi_call_on_customer( + self, + ): + contact = self.env["res.partner"].create( + {"name": "test", "parent_id": self.partner.id} + ) + self.partner.upflow_uuid = str(uuid4()) + + domain = [ + ("model", "=", "res.partner"), + ( + "type_id", + "=", + self.env.ref( + "edi_upflow.upflow_edi_exchange_type_upflow_post_customers" + ).id, + ), + ] + records = self.env["edi.exchange.record"].search(domain) + self.assertEqual(len(records), 0) + with trap_jobs() as trap: + contact.name = "test 2" + trap.assert_jobs_count(1) + + records = self.env["edi.exchange.record"].search(domain) + self.assertEqual(len(records), 1) + exchange_partners = [r.record for r in records] + self.assertTrue(self.partner in exchange_partners) + + def test_contact_already_exist_and_link_to_customer_setting_email(self): + contact = self.env["res.partner"].create( + {"name": "test", "parent_id": self.partner.id} + ) + self.partner.upflow_uuid = str(uuid4()) + + domain = [ + ("model", "=", "res.partner"), + ( + "type_id", + "=", + self.env.ref( + "edi_upflow.upflow_edi_exchange_type_upflow_post_customers" + ).id, + ), + ] + records = self.env["edi.exchange.record"].search(domain) + self.assertEqual(len(records), 0) + with trap_jobs() as trap: + contact.email = "example@email.com" + trap.assert_jobs_count(1) + + records = self.env["edi.exchange.record"].search(domain) + self.assertEqual(len(records), 1) + exchange_partners = [r.record for r in records] + self.assertTrue(self.partner in exchange_partners) + + def test_upflow_post_customers_generate(self): + record = self.backend.create_record( + "upflow_post_customers", + { + "model": self.invoice.commercial_partner_id._name, + "res_id": self.invoice.commercial_partner_id.id, + }, + ) + record.action_exchange_generate() + self.assertTrue( + record._get_file_content(), + ) + + def test_upflow_post_customers_match_generate(self): + record = self.backend.create_record( + "upflow_post_customers", + { + "model": self.invoice.commercial_partner_id._name, + "res_id": self.invoice.commercial_partner_id.id, + }, + ) + generated_content = '{"some": "value"}' + with mock.patch( + "odoo.addons.edi_upflow.components" + ".edi_output_generate_upflow_post_customers" + ".EdiOutputGenerateUpflowPostCustomers.generate", + return_value=generated_content, + ) as m_generate: + record.action_exchange_generate() + m_generate.assert_called_once() + self.assertEqual(record._get_file_content(), generated_content) + + def test_upflow_post_customers_check(self): + + contact1 = self.env["res.partner"].create({"name": "test 1"}) + self.invoice.commercial_partner_id.child_ids = [(6, 0, [contact1.id])] + + record = self.backend.create_record( + "upflow_post_customers", + { + "model": self.invoice.commercial_partner_id._name, + "res_id": self.invoice.commercial_partner_id.id, + }, + ) + uuid = str(uuid4()) + uuid2 = str(uuid4()) + record._set_file_content( + json.dumps( + { + "id": uuid, + "contacts": [ + { + "id": uuid2, + "externalId": contact1.id, + } + ], + } + ), + field_name="ws_response_content", + ) + record.ws_response_status_code = 200 + self.backend._exchange_output_check_state(record) + self.assertEqual(record.edi_exchange_state, "output_sent_and_processed") + self.assertEqual(self.invoice.commercial_partner_id.upflow_uuid, uuid) + self.assertEqual(contact1.upflow_uuid, uuid2) + + def test_upflow_post_customers_check_contact_null_external_id(self): + contact1 = self.env["res.partner"].create({"name": "test 1"}) + self.invoice.commercial_partner_id.child_ids = [(6, 0, [contact1.id])] + record = self.backend.create_record( + "upflow_post_customers", + { + "model": self.invoice.commercial_partner_id._name, + "res_id": self.invoice.commercial_partner_id.id, + }, + ) + uuid = str(uuid4()) + uuid2 = str(uuid4()) + record._set_file_content( + json.dumps( + { + "id": uuid, + "contacts": [ + { + "id": uuid2, + "externalId": None, + } + ], + } + ), + field_name="ws_response_content", + ) + record.ws_response_status_code = 200 + with self.assertLogs(check_upflow_post_customer_logger, level="WARNING") as log: + self.backend._exchange_output_check_state(record) + self.assertIn( + "WARNING:odoo.addons.edi_upflow.components" + ".edi_output_check_upflow_post_customers:" + "No externalId found for contact %s" % uuid2, + log.output, + ) + self.assertNotEqual(contact1.upflow_uuid, uuid2) + + def test_upflow_put_contacts_match_generate(self): + contact = self.env["res.partner"].create( + {"name": "test 1", "email": "test@foodles.co"} + ) + self.partner.child_ids = [(6, 0, [contact.id])] + record = self.backend.create_record( + "upflow_put_contacts", + { + "model": contact._name, + "res_id": contact.id, + }, + ) + generated_content = '{"some": "value"}' + with mock.patch( + "odoo.addons.edi_upflow.components" + ".edi_output_generate_upflow_put_contacts" + ".EdiOutputGenerateUpflowPutContacts.generate", + return_value=generated_content, + ) as m_generate: + record.action_exchange_generate() + m_generate.assert_called_once() + self.assertEqual(record._get_file_content(), generated_content) + + @mock.patch( + "odoo.addons.base_upflow.models.res_partner.Partner" + ".get_upflow_api_post_contacts_payload", + return_value={"some": "value"}, + ) + def test_upflow_put_contacts_call_post_contact_payload(self, m_payload): + contact = self.env["res.partner"].create( + {"name": "test 1", "email": "test@foodles.co"} + ) + self.partner.child_ids = [(6, 0, [contact.id])] + record = self.backend.create_record( + "upflow_put_contacts", + { + "model": contact._name, + "res_id": contact.id, + }, + ) + record.action_exchange_generate() + m_payload.assert_called_once() + self.assertEqual(record._get_file_content(), json.dumps({"some": "value"})) + + def test_upflow_put_contacts_check(self): + record = self.backend.create_record( + "upflow_put_contacts", + { + "model": self.partner._name, + "res_id": self.partner.id, + }, + ) + uuid = str(uuid4()) + record._set_file_content( + json.dumps({"id": uuid}), field_name="ws_response_content" + ) + record.ws_response_status_code = 200 + self.backend._exchange_output_check_state(record) + self.assertEqual(record.edi_exchange_state, "output_sent_and_processed") + self.assertEqual(self.partner.upflow_uuid, uuid) diff --git a/edi_upflow/tests/test_edi_upflow_error.py b/edi_upflow/tests/test_edi_upflow_error.py new file mode 100644 index 000000000..d126d2f73 --- /dev/null +++ b/edi_upflow/tests/test_edi_upflow_error.py @@ -0,0 +1,43 @@ +from odoo.tests.common import tagged + +from odoo.addons.edi_upflow.components.edi_output_generate_upflow_post_customers import ( + EdiOutputGenerateUpflowPostCustomersError, +) +from odoo.addons.edi_upflow.components.edi_output_generate_upflow_post_reconcile import ( + EdiOutputGenerateUpflowPostReconcileError, +) + +from .common import EDIUpflowCommonCase + + +@tagged("post_install", "-at_install") +class TestEdiUpflowError(EDIUpflowCommonCase): + def test_generate_post_reconcile_with_nothing_to_reconcile(self): + partial_reconcile = self.env["account.partial.reconcile"].browse() + record = self.backend.create_record( + "upflow_post_reconcile", + { + "model": partial_reconcile._name, + "res_id": partial_reconcile.id, + }, + ) + with self.assertRaisesRegex( + EdiOutputGenerateUpflowPostReconcileError, + "No record found to generate the payload.", + ): + record.action_exchange_generate() + + def test_generate_post_customers_with_no_customer(self): + customer = self.env["res.partner"].browse() + record = self.backend.create_record( + "upflow_post_customers", + { + "model": customer._name, + "res_id": customer.id, + }, + ) + with self.assertRaisesRegex( + EdiOutputGenerateUpflowPostCustomersError, + "No record found to generate the payload.", + ): + record.action_exchange_generate() diff --git a/edi_upflow/tests/test_multi_company_backend.py b/edi_upflow/tests/test_multi_company_backend.py new file mode 100644 index 000000000..70c477b33 --- /dev/null +++ b/edi_upflow/tests/test_multi_company_backend.py @@ -0,0 +1,74 @@ +from uuid import uuid4 + +from odoo.tests.common import tagged + +from odoo.addons.queue_job.tests.common import trap_jobs + +from .common import EDIUpflowCommonCase + + +@tagged("post_install", "-at_install") +class TestNoCompanyBackendSet(EDIUpflowCommonCase): + """Invoices flows POST v1/invoices""" + + @classmethod + def _setup_records(cls): + super()._setup_records() + cls.invoice = cls._create_invoice(auto_validate=False) + cls.env.company.upflow_backend_id = False + + def test_post_invoice_do_not_create_exchange_record(self): + domain = [ + ("model", "=", "account.move"), + ("res_id", "=", self.invoice.id), + ( + "type_id", + "=", + self.env.ref("edi_upflow.upflow_edi_exchange_type_post_invoices").id, + ), + ] + self.assertEqual(self.env["edi.exchange.record"].search_count(domain), 0) + + with trap_jobs() as trap: + self.invoice.action_post() + trap.assert_jobs_count(0, only=self.backend.exchange_generate) + self.assertEqual(self.env["edi.exchange.record"].search_count(domain), 0) + + def test_on_create_account_full_reconcile_do_not_create_exchange_record(self): + domain = [ + ("model", "=", "account.partial.reconcile"), + ( + "type_id", + "=", + self.env.ref("edi_upflow.upflow_edi_exchange_type_post_reconcile").id, + ), + ] + self.assertEqual(self.env["edi.exchange.record"].search_count(domain), 0) + with trap_jobs() as trap: + self.invoice.action_post() + self._register_manual_payment_reconciled(self.invoice) + trap.assert_jobs_count(0, only=self.backend.exchange_generate) + self.assertEqual(self.env["edi.exchange.record"].search_count(domain), 0) + + def test_post_invoice_on_already_synched_customer_create_exchange_record(self): + self.partner.upflow_edi_backend_id = self.backend + self.partner.upflow_uuid = str(uuid4()) + domain = [ + ("model", "=", "account.move"), + ("res_id", "=", self.invoice.id), + ( + "type_id", + "=", + self.env.ref("edi_upflow.upflow_edi_exchange_type_post_invoices").id, + ), + ] + records = self.env["edi.exchange.record"].search(domain) + self.assertEqual(len(records), 0) + + with trap_jobs() as trap: + self.invoice.action_post() + # 1 invoice + 1 PDF => 2 jobs + trap.assert_jobs_count(2) + records = self.env["edi.exchange.record"].search(domain) + self.assertEqual(len(records), 1) + self.assertEqual(records.edi_exchange_state, "new") diff --git a/edi_upflow/views/account_full_reconcile.xml b/edi_upflow/views/account_full_reconcile.xml new file mode 100644 index 000000000..3aac7301b --- /dev/null +++ b/edi_upflow/views/account_full_reconcile.xml @@ -0,0 +1,30 @@ + + + + + account.full.reconcile.form (in edi_account) + account.full.reconcile + + + + + + + + + + + + + + + diff --git a/edi_upflow/views/account_partial_reconcile.xml b/edi_upflow/views/account_partial_reconcile.xml new file mode 100644 index 000000000..dc5718ec6 --- /dev/null +++ b/edi_upflow/views/account_partial_reconcile.xml @@ -0,0 +1,96 @@ + + + + + account.partial.reconcile.form (in edi_account) + account.partial.reconcile + +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+
+
+
+ + account.partial.reconcile.tree (in edi_account) + account.partial.reconcile + + + + + + + + + + + Account partial reconcile + ir.actions.act_window + account.partial.reconcile + form + tree,form + + + + + +
diff --git a/edi_upflow/views/edi_exchange_record.xml b/edi_upflow/views/edi_exchange_record.xml new file mode 100644 index 000000000..babcb7b23 --- /dev/null +++ b/edi_upflow/views/edi_exchange_record.xml @@ -0,0 +1,31 @@ + + + + + + edi.exchange.record + + + + + + + + + + + + + + + + + diff --git a/edi_upflow/views/res_config_settings.xml b/edi_upflow/views/res_config_settings.xml new file mode 100644 index 000000000..e2ab03bd5 --- /dev/null +++ b/edi_upflow/views/res_config_settings.xml @@ -0,0 +1,28 @@ + + + + + res.config.settings.view.form.inherit edi upflow + res.config.settings + + + +
+
+
+ Upflow backend +
+ Upflow backend (on the current company) +
+
+ +
+
+
+ + + + + diff --git a/edi_upflow/views/res_partner.xml b/edi_upflow/views/res_partner.xml new file mode 100644 index 000000000..d585b92e4 --- /dev/null +++ b/edi_upflow/views/res_partner.xml @@ -0,0 +1,34 @@ + + + + + + res.partner.form.inherit + res.partner + + 50 + +
+ +
+ + + + +
+
diff --git a/edi_upflow/views/webservice_backend.xml b/edi_upflow/views/webservice_backend.xml new file mode 100644 index 000000000..457692441 --- /dev/null +++ b/edi_upflow/views/webservice_backend.xml @@ -0,0 +1,31 @@ + + + + + + webservice.backend.form (in webservice) + webservice.backend + + + + + + + + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..8f9d9a12b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +# generated from manifests external_dependencies +responses diff --git a/setup/edi_upflow/odoo/addons/edi_upflow b/setup/edi_upflow/odoo/addons/edi_upflow new file mode 120000 index 000000000..099ffc0a9 --- /dev/null +++ b/setup/edi_upflow/odoo/addons/edi_upflow @@ -0,0 +1 @@ +../../../../edi_upflow \ No newline at end of file diff --git a/setup/edi_upflow/setup.py b/setup/edi_upflow/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/edi_upflow/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 000000000..2f896bfbf --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,4 @@ +odoo14-addon-base-upflow @ git+https://github.com/OCA/credit-control.git@refs/pull/344/head#subdirectory=setup/base_upflow +# Those PRs allows to save HTTP responses on EDI Exchange +odoo14-addon-webservice @ git+https://github.com/OCA/web-api.git@refs/pull/22/head#subdirectory=setup/webservice +odoo14-addon-edi_webservice_oca @ git+https://github.com/OCA/edi.git@refs/pull/787/head#subdirectory=setup/edi_webservice_oca