diff --git a/README.md b/README.md index 7eec74965..5406a0b2f 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ addon | version | maintainers | summary --- | --- | --- | --- [edi_account_core_oca](edi_account_core_oca/) | 18.0.1.1.1 | etobella | Define EDI Configuration for Account Moves [edi_account_oca](edi_account_oca/) | 18.0.1.1.1 | etobella | Define some component listeners for Account Moves -[edi_component_oca](edi_component_oca/) | 18.0.1.0.3 | simahawk etobella | Allow to use Connector as a source in EDI -[edi_core_oca](edi_core_oca/) | 18.0.1.6.6 | simahawk etobella | Define backends, exchange types, exchange records, basic automation and views for handling EDI exchanges. +[edi_component_oca](edi_component_oca/) | 18.0.1.1.0 | simahawk etobella | Allow to use Connector as a source in EDI +[edi_core_oca](edi_core_oca/) | 18.0.1.7.0 | simahawk etobella | Define backends, exchange types, exchange records, basic automation and views for handling EDI exchanges. [edi_endpoint_oca](edi_endpoint_oca/) | 18.0.1.0.3 | | Base module allowing configuration of custom endpoints for EDI framework. [edi_exchange_template_oca](edi_exchange_template_oca/) | 18.0.1.3.3 | simahawk | Allows definition of exchanges via templates. [edi_exchange_template_party_data](edi_exchange_template_party_data/) | 18.0.1.0.1 | simahawk | Glue module between edi_exchange_template and edi_party_data @@ -33,6 +33,7 @@ addon | version | maintainers | summary [edi_oca](edi_oca/) | 18.0.1.5.2 | simahawk etobella | Integrate all EDI modules together [edi_party_data_oca](edi_party_data_oca/) | 18.0.1.0.1 | simahawk | Allow to configure and retrieve party information for EDI exchanges. [edi_product_oca](edi_product_oca/) | 18.0.1.0.0 | | EDI framework configuration and base logic for products and products packaging +[edi_purchase_oca](edi_purchase_oca/) | 18.0.1.0.0 | | Define EDI Configuration for Purchase Orders [edi_queue_oca](edi_queue_oca/) | 18.0.1.0.2 | | Set Queue Jobs on EDI [edi_record_metadata_oca](edi_record_metadata_oca/) | 18.0.1.0.5 | simahawk | Allow to store metadata for related records. [edi_sale_endpoint](edi_sale_endpoint/) | 18.0.1.0.0 | simahawk | Glue module between edi_sale_oca and edi_endpoint_oca. diff --git a/edi_component_oca/README.rst b/edi_component_oca/README.rst index db2cffdd7..06d07a3d0 100644 --- a/edi_component_oca/README.rst +++ b/edi_component_oca/README.rst @@ -11,7 +11,7 @@ Edi Connector Oca !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:81c11c0d670f363513d25e5d2d6cb038f1fc56580f20c837c2d2a7665798018d + !! source digest: sha256:6c5e69ae42fdaaaf428ee2b9d0a8e14ba1b5515b6fd5daa7ca926cb5800567b4 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png diff --git a/edi_component_oca/__manifest__.py b/edi_component_oca/__manifest__.py index fa8111718..c589696e7 100644 --- a/edi_component_oca/__manifest__.py +++ b/edi_component_oca/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Edi Connector Oca", "summary": """Allow to use Connector as a source in EDI""", - "version": "18.0.1.0.3", + "version": "18.0.1.1.0", "license": "LGPL-3", "author": "ACSONE,Dixmit,Camptocamp,Odoo Community Association (OCA)", "maintainers": ["simahawk", "etobella"], diff --git a/edi_component_oca/models/edi_exchange_record.py b/edi_component_oca/models/edi_exchange_record.py index 551d1fa6b..b6105c0d5 100644 --- a/edi_component_oca/models/edi_exchange_record.py +++ b/edi_component_oca/models/edi_exchange_record.py @@ -8,15 +8,9 @@ class EdiExchangeRecord(models.Model): _inherit = "edi.exchange.record" - def _trigger_edi_event_make_name(self, name, suffix=None): - return "on_edi_exchange_{name}{suffix}".format( - name=name, - suffix=("_" + suffix) if suffix else "", - ) - def _trigger_edi_event(self, name, suffix=None, target=None, **kw): """Trigger a component event linked to this backend and edi exchange.""" - name = self._trigger_edi_event_make_name(name, suffix=suffix) + event_name = self._trigger_edi_event_make_name(name, suffix=suffix) target = target or self - target._event(name).notify(self, **kw) + target._event(event_name).notify(self, **kw) return super()._trigger_edi_event(name, suffix=suffix, target=target, **kw) diff --git a/edi_component_oca/static/description/index.html b/edi_component_oca/static/description/index.html index e6128b6fa..f256369c0 100644 --- a/edi_component_oca/static/description/index.html +++ b/edi_component_oca/static/description/index.html @@ -372,7 +372,7 @@

Edi Connector Oca

!! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:81c11c0d670f363513d25e5d2d6cb038f1fc56580f20c837c2d2a7665798018d +!! source digest: sha256:6c5e69ae42fdaaaf428ee2b9d0a8e14ba1b5515b6fd5daa7ca926cb5800567b4 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

Beta License: LGPL-3 OCA/edi-framework Translate me on Weblate Try me on Runboat

This module allows to use components to handle code to execute on EDI diff --git a/edi_core_oca/README.rst b/edi_core_oca/README.rst index bb985bd0d..55bdc1e55 100644 --- a/edi_core_oca/README.rst +++ b/edi_core_oca/README.rst @@ -11,7 +11,7 @@ EDI !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:c609033733302fa71a3c01c11e2729fd2b47ccde0b9a1d0619bed03cc26db4fe + !! source digest: sha256:27258fb23153f2660be19d7c76b04c4d09d35d1e240b779076c7f4a9fa66d9f2 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png @@ -42,7 +42,15 @@ Provides following models: 3. EDI Exchange Type, to define file types of exchange 4. EDI Exchange Record, to define a record exchanged between systems -Also define a mixin to be inherited by records that will generate EDIs +Also define a mixin to be inherited by records that will generate EDIs. + +In addition, the module ships an ``edi.configuration`` mechanism that +lets users react to EDI events declaratively, by writing small Python +snippets attached to event triggers. This can be used as a lightweight +alternative to component event listeners: configurations can react +globally (on any exchange) or be scoped to a specific partner (or any +related record), exchange type, backend and target model. See +``CONFIGURE.md`` for details. **Table of contents** @@ -130,6 +138,104 @@ backend to be used for the exchange. In case of "Custom" kind, you'll have to define your own logic to do something. +Custom event handlers via ``edi.configuration`` +----------------------------------------------- + +The framework can dispatch EDI lifecycle events to user-defined +configurations, providing a declarative alternative to component events. +Each ``edi.configuration`` record links a **trigger** (an +``edi.configuration.trigger`` code) to a **snippet** (``snippet_do``) +that is executed every time the matching event fires on an exchange +record. + +Built-in events fired by ``EDIExchangeRecord`` include: + +- ``on_edi_exchange_done`` — exchange processed successfully +- ``on_edi_exchange_error`` — exchange ended in error +- ``on_edi_exchange_done_ack_received`` — ACK file received +- ``on_edi_exchange_done_ack_missing`` — expected ACK not received +- ``on_edi_exchange_done_ack_received_error`` — ACK received with errors +- ``on_edi_exchange__complete`` — generic action completion + (e.g. ``generate_complete``, ``send_complete``), fired once on the + exchange record and once on its related record when present + +The snippet receives at least two variables in its evaluation context: + +- ``conf`` — the current ``edi.configuration`` record +- ``record`` — the target of the event (either the + ``edi.exchange.record`` itself or its related business record) + +Plus the standard ``edi_exec_snippet_do`` extras (``operation``, +``edi_action``, ``old_value``, ``vals``, ...). + +Two complementary lookup modes are available, and they can be combined +freely on the same flow. + +Global event configurations +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Use this mode when you want a configuration to react to events on **any +business record** that travels through EDI, with no per-partner setup. + +Tick **Global Configuration** (``is_global``) on the +``edi.configuration``. When an event fires, the framework calls +``edi.configuration.edi_get_conf_global(exchange_record, trigger)`` +which selects all active global configurations whose ``trigger`` matches +the event code, filtered by the originating exchange record: + +- **Exchange type** (``type_id``): must match the exchange record's + type, or be left empty to apply to every type +- **Backend** (``backend_id``): must match the exchange record's + backend, or be left empty to apply to every backend +- **Model** (``model_id`` / ``model_name``): must match the related + record model (e.g. ``sale.order``, ``account.move``), or be left empty + to apply to every model + +Empty values mean "applies to all". Inactive configurations and +non-global configurations are ignored. All matching configurations are +executed in sequence. + +Typical use cases: + +- Posting a generic chatter message on every exchange that ends in error +- Pushing a notification to an external system every time an ACK is + received for a given backend +- Logging extra audit information for every exchange of a given type + +Partner-specific (relation-based) event configurations +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Use this mode when the reaction must depend on the partner (or any other +related record) involved in the exchange. + +In this case configurations are **not** marked as global. Instead, the +business record exposes an ``edi_config_ids`` relation (via +``edi.exchange.consumer.mixin._edi_config_field_relation``, which by +default returns ``self.env["edi.configuration"]`` and can be overridden, +for example to point at ``self.partner_id.edi_config_ids``). When an +event fires on the business record (e.g. on create, on write, on +send-via-email/EDI), the framework calls +``edi_confs.edi_get_conf(trigger)`` on that relation and runs the +matching snippets. + +Compared with global configurations: + +- **Discovery** comes from the record's own relation, not from a + database-wide search; this is the right place to model "this partner + wants this behaviour" rules +- **Filtering** is reduced to ``trigger`` and (optionally) + ``backend_id``, since the recordset is already narrowed by the + relation +- The same ``snippet_do`` API applies, so a snippet can be reused + verbatim between global and partner-specific configurations + +Typical use cases: + +- Sending a specific EDI flow only for a subset of partners +- Customising the document generation per customer (e.g. different email + template, different transport) +- Switching between EDI and email delivery based on partner preferences + Usage ===== @@ -182,6 +288,39 @@ Components dependancy has been removed and set on a new dependant module ``edi_component_oca``. Module ``edi_oca`` has been_renamed to ``edi_core_oca``. +Changelog +========= + +18.0.1.7.0 (2026-05-20) +----------------------- + +Features +~~~~~~~~ + +- Introduce a new system for **global EDI events** based on + ``edi.configuration`` that can replace the use of component events. + + Any ``edi.configuration`` flagged as ``is_global`` is now picked up by + ``EDIExchangeRecord._trigger_edi_event`` and its ``snippet_do`` is + executed whenever the matching event fires (``done``, ``error``, + ``ack_received``, ``ack_missing``, ``ack_received_error``, + ``_complete``, ...). + + Filtering is performed via the new + ``edi.configuration.edi_get_conf_global`` model method, which selects + active global configurations matching the event trigger code and, when + set, the exchange type, the backend and the related record model + carried by the exchange record (empty values still mean "applies to + all"). This lets integrators subscribe to EDI events declaratively + from the UI instead of writing component listeners. + + Full test coverage is included for the dispatch on all ``notify_*`` + events (both on the exchange record and on the related record target) + and for the new filtering rules. + + Last but not lease: add minimal docs for edi.configuration. + (`#global-edi-conf-events `__) + Bug Tracker =========== diff --git a/edi_core_oca/__manifest__.py b/edi_core_oca/__manifest__.py index a2929a7ab..c3a09f07b 100644 --- a/edi_core_oca/__manifest__.py +++ b/edi_core_oca/__manifest__.py @@ -9,7 +9,7 @@ Define backends, exchange types, exchange records, basic automation and views for handling EDI exchanges. """, - "version": "18.0.1.6.6", + "version": "18.0.1.7.0", "website": "https://github.com/OCA/edi-framework", "development_status": "Beta", "license": "LGPL-3", diff --git a/edi_core_oca/data/edi_configuration.xml b/edi_core_oca/data/edi_configuration.xml index 565919c74..867212198 100644 --- a/edi_core_oca/data/edi_configuration.xml +++ b/edi_core_oca/data/edi_configuration.xml @@ -15,6 +15,18 @@ on_record_write Trigger when a record is updated + + + On record exchange done + on_edi_exchange_done + Trigger when a record exchange is done + + + On record exchange error + on_edi_exchange_error + Trigger when a record exchange has an error + + Send via email diff --git a/edi_core_oca/i18n/edi_core_oca.pot b/edi_core_oca/i18n/edi_core_oca.pot index 436bf77b7..35b6d0b94 100644 --- a/edi_core_oca/i18n/edi_core_oca.pot +++ b/edi_core_oca/i18n/edi_core_oca.pot @@ -1005,6 +1005,11 @@ msgstr "" msgid "Generator" msgstr "" +#. module: edi_core_oca +#: model:ir.model.fields,field_description:edi_core_oca.field_edi_configuration__is_global +msgid "Global Configuration" +msgstr "" + #. module: edi_core_oca #: model_terms:ir.ui.view,arch_db:edi_core_oca.edi_exchange_record_view_search #: model_terms:ir.ui.view,arch_db:edi_core_oca.edi_exchange_type_rule_view_search @@ -1069,6 +1074,13 @@ msgstr "" msgid "If checked, some messages have a delivery error." msgstr "" +#. module: edi_core_oca +#: model:ir.model.fields,help:edi_core_oca.field_edi_configuration__is_global +msgid "" +"If checked, this configuration will be executed for all records, regardless " +"of the partner relation." +msgstr "" + #. module: edi_core_oca #: model:ir.model.fields,help:edi_core_oca.field_edi_exchange_type__exchange_filename_sequence_id msgid "" diff --git a/edi_core_oca/i18n/es.po b/edi_core_oca/i18n/es.po index 6aa91ccbc..ae4534243 100644 --- a/edi_core_oca/i18n/es.po +++ b/edi_core_oca/i18n/es.po @@ -1014,6 +1014,11 @@ msgstr "" msgid "Generator" msgstr "" +#. module: edi_core_oca +#: model:ir.model.fields,field_description:edi_core_oca.field_edi_configuration__is_global +msgid "Global Configuration" +msgstr "" + #. module: edi_core_oca #: model_terms:ir.ui.view,arch_db:edi_core_oca.edi_exchange_record_view_search #: model_terms:ir.ui.view,arch_db:edi_core_oca.edi_exchange_type_rule_view_search @@ -1078,6 +1083,13 @@ msgstr "" msgid "If checked, some messages have a delivery error." msgstr "" +#. module: edi_core_oca +#: model:ir.model.fields,help:edi_core_oca.field_edi_configuration__is_global +msgid "" +"If checked, this configuration will be executed for all records, regardless " +"of the partner relation." +msgstr "" + #. module: edi_core_oca #: model:ir.model.fields,help:edi_core_oca.field_edi_exchange_type__exchange_filename_sequence_id msgid "" diff --git a/edi_core_oca/i18n/fr.po b/edi_core_oca/i18n/fr.po index 28a967be4..b09c20623 100644 --- a/edi_core_oca/i18n/fr.po +++ b/edi_core_oca/i18n/fr.po @@ -1026,6 +1026,11 @@ msgstr "" msgid "Generator" msgstr "Générer" +#. module: edi_core_oca +#: model:ir.model.fields,field_description:edi_core_oca.field_edi_configuration__is_global +msgid "Global Configuration" +msgstr "" + #. module: edi_core_oca #: model_terms:ir.ui.view,arch_db:edi_core_oca.edi_exchange_record_view_search #: model_terms:ir.ui.view,arch_db:edi_core_oca.edi_exchange_type_rule_view_search @@ -1092,6 +1097,13 @@ msgstr "" msgid "If checked, some messages have a delivery error." msgstr "" +#. module: edi_core_oca +#: model:ir.model.fields,help:edi_core_oca.field_edi_configuration__is_global +msgid "" +"If checked, this configuration will be executed for all records, regardless " +"of the partner relation." +msgstr "" + #. module: edi_core_oca #: model:ir.model.fields,help:edi_core_oca.field_edi_exchange_type__exchange_filename_sequence_id msgid "" diff --git a/edi_core_oca/i18n/it.po b/edi_core_oca/i18n/it.po index 14ad64c81..3147d8f34 100644 --- a/edi_core_oca/i18n/it.po +++ b/edi_core_oca/i18n/it.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 17.0\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2026-04-30 10:45+0000\n" +"PO-Revision-Date: 2026-05-25 19:57+0000\n" "Last-Translator: mymage \n" "Language-Team: none\n" "Language: it\n" @@ -1099,6 +1099,11 @@ msgstr "Genera record di scambio" msgid "Generator" msgstr "Generatore" +#. module: edi_core_oca +#: model:ir.model.fields,field_description:edi_core_oca.field_edi_configuration__is_global +msgid "Global Configuration" +msgstr "Configurazione globale" + #. module: edi_core_oca #: model_terms:ir.ui.view,arch_db:edi_core_oca.edi_exchange_record_view_search #: model_terms:ir.ui.view,arch_db:edi_core_oca.edi_exchange_type_rule_view_search @@ -1170,6 +1175,15 @@ msgstr "Se selezionata, nuovi messaggi richiedono attenzione." msgid "If checked, some messages have a delivery error." msgstr "Se selezionata, alcuni messaggi hanno un errore di consegna." +#. module: edi_core_oca +#: model:ir.model.fields,help:edi_core_oca.field_edi_configuration__is_global +msgid "" +"If checked, this configuration will be executed for all records, regardless " +"of the partner relation." +msgstr "" +"Se selezionata, questa configurazione verrà eseguita per tutti i record, " +"indipendentemente dalla relazione con il partner." + #. module: edi_core_oca #: model:ir.model.fields,help:edi_core_oca.field_edi_exchange_type__exchange_filename_sequence_id msgid "" diff --git a/edi_core_oca/models/edi_backend.py b/edi_core_oca/models/edi_backend.py index 54c845480..037ead3e9 100644 --- a/edi_core_oca/models/edi_backend.py +++ b/edi_core_oca/models/edi_backend.py @@ -10,6 +10,8 @@ import traceback from io import StringIO +from psycopg2 import IntegrityError, OperationalError + from odoo import exceptions, fields, models from odoo.exceptions import UserError @@ -249,6 +251,11 @@ def exchange_send(self, exchange_record): _logger.debug( "%s send failed. Marked as errored.", exchange_record.identifier ) + except (OperationalError, IntegrityError): + # We don't want the finally block to be executed in this case as + # the cursor is already in an aborted state and any query will fail. + res = "__sql_error__" + raise else: # TODO: maybe the send handler should return desired message and state message = exchange_record._exchange_status_message("send_ok") @@ -260,16 +267,18 @@ def exchange_send(self, exchange_record): ) res = message finally: - exchange_record.write( - { - "edi_exchange_state": state, - "exchange_error": error, - "exchange_error_traceback": traceback, - # FIXME: this should come from _compute_exchanged_on - # but somehow it's failing in send tests (in record tests it works). - "exchanged_on": fields.Datetime.now(), - } - ) + if res != "__sql_error__": + exchange_record.write( + { + "edi_exchange_state": state, + "exchange_error": error, + "exchange_error_traceback": traceback, + # FIXME: this should come from _compute_exchanged_on + # but somehow it's failing in send tests + # (in record tests it works). + "exchanged_on": fields.Datetime.now(), + } + ) exchange_record.notify_action_complete("send", message=message) return res @@ -458,20 +467,27 @@ def exchange_process(self, exchange_record): error = _get_exception_msg(err) state = "input_processed_error" res = f"Error: {error}" + except (OperationalError, IntegrityError): + # We don't want the finally block to be executed in this case as + # the cursor is already in an aborted state and any query will fail. + res = "__sql_error__" + raise else: error = traceback = None state = "input_processed" finally: - exchange_record.write( - { - "edi_exchange_state": state, - "exchange_error": error, - "exchange_error_traceback": traceback, - # FIXME: this should come from _compute_exchanged_on - # but somehow it's failing in send tests (in record tests it works). - "exchanged_on": fields.Datetime.now(), - } - ) + if res != "__sql_error__": + exchange_record.write( + { + "edi_exchange_state": state, + "exchange_error": error, + "exchange_error_traceback": traceback, + # FIXME: this should come from _compute_exchanged_on + # but somehow it's failing in send tests + # (in record tests it works). + "exchanged_on": fields.Datetime.now(), + } + ) if ( state == "input_processed_error" and old_state != "input_processed_error" @@ -519,22 +535,29 @@ def exchange_receive(self, exchange_record): state = "input_receive_error" message = exchange_record._exchange_status_message("receive_ko") res = f"Input error: {error}" + except (OperationalError, IntegrityError): + # We don't want the finally block to be executed in this case as + # the cursor is already in an aborted state and any query will fail. + res = "__sql_error__" + raise else: message = exchange_record._exchange_status_message("receive_ok") error = traceback = None state = "input_received" res = message finally: - exchange_record.write( - { - "edi_exchange_state": state, - "exchange_error": error, - "exchange_error_traceback": traceback, - # FIXME: this should come from _compute_exchanged_on - # but somehow it's failing in send tests (in record tests it works). - "exchanged_on": fields.Datetime.now(), - } - ) + if res != "__sql_error__": + exchange_record.write( + { + "edi_exchange_state": state, + "exchange_error": error, + "exchange_error_traceback": traceback, + # FIXME: this should come from _compute_exchanged_on + # but somehow it's failing in send tests + # (in record tests it works). + "exchanged_on": fields.Datetime.now(), + } + ) exchange_record.notify_action_complete("receive", message=message) return res diff --git a/edi_core_oca/models/edi_configuration.py b/edi_core_oca/models/edi_configuration.py index 93d7412f3..82bade7d5 100644 --- a/edi_core_oca/models/edi_configuration.py +++ b/edi_core_oca/models/edi_configuration.py @@ -67,16 +67,25 @@ class EdiConfiguration(models.Model): help="""Used to do something specific here. Receives: operation, edi_action, vals, old_vals.""", ) + # You can use this to avoid component events ;) + is_global = fields.Boolean( + string="Global Configuration", + help="If checked, this configuration will be executed for all records, " + "regardless of the partner relation.", + default=False, + ) @api.constrains("backend_id", "type_id") def _constrains_backend(self): for rec in self: + if not rec.backend_id: + continue if rec.type_id.backend_id: if rec.type_id.backend_id != rec.backend_id: raise exceptions.ValidationError( self.env._("Backend must match with exchange type's backend!") ) - else: + elif rec.type_id: if rec.type_id.backend_type_id != rec.backend_id.backend_type_id: raise exceptions.ValidationError( self.env._( @@ -201,6 +210,35 @@ def edi_get_conf(self, trigger, backend=None): domain.append(("backend_id", "in", backend_ids)) return self.filtered_domain(domain) + @api.model + def edi_get_conf_global(self, exchange_record, trigger): + """Return active global configurations matching the given event. + + Unlike :meth:`edi_get_conf` -- which runs on a recordset of + configurations already linked to a partner -- global configurations + are not bound to any partner. We therefore have to derive the + filtering keys from the originating exchange record: + + * ``trigger`` must match the event code + * ``is_global`` must be True + * ``type_id`` must match the exchange type or be empty (applies to all) + * ``backend_id`` must match the backend or be empty (applies to all) + * ``model_name`` must match the related record model or be empty + (applies to all) + """ + related_model = exchange_record.model + model_options = [False] + if related_model: + model_options.append(related_model) + domain = [ + ("trigger", "=", trigger), + ("is_global", "=", True), + ("type_id", "in", [exchange_record.type_id.id, False]), + ("backend_id", "in", [exchange_record.backend_id.id, False]), + ("model_name", "in", model_options), + ] + return self.search(domain) + def action_view_partners(self): # TODO: add tests partner_model = self.env["res.partner"] diff --git a/edi_core_oca/models/edi_exchange_record.py b/edi_core_oca/models/edi_exchange_record.py index a87b4506e..685a98bb0 100644 --- a/edi_core_oca/models/edi_exchange_record.py +++ b/edi_core_oca/models/edi_exchange_record.py @@ -533,8 +533,19 @@ def _notify_related_record(self, message, level="info"): rec._notify_related_record(message, level) def _trigger_edi_event(self, name, suffix=None, target=None, **kw): - """Hook to be implemented in other modules""" - pass + event_name = self._trigger_edi_event_make_name(name, suffix) + target = target or self + global_configs = self.env["edi.configuration"].edi_get_conf_global( + self, event_name + ) + for conf in global_configs: + conf.edi_exec_snippet_do(target, **kw) + + def _trigger_edi_event_make_name(self, name, suffix=None): + return "on_edi_exchange_{name}{suffix}".format( + name=name, + suffix=("_" + suffix) if suffix else "", + ) def _notify_done(self): self._notify_related_record(self._exchange_status_message("process_ok")) diff --git a/edi_core_oca/readme/CONFIGURE.md b/edi_core_oca/readme/CONFIGURE.md index 48045deba..a1957bbe0 100644 --- a/edi_core_oca/readme/CONFIGURE.md +++ b/edi_core_oca/readme/CONFIGURE.md @@ -63,3 +63,98 @@ backend to be used for the exchange. In case of "Custom" kind, you'll have to define your own logic to do something. + +## Custom event handlers via `edi.configuration` + +The framework can dispatch EDI lifecycle events to user-defined +configurations, providing a declarative alternative to component events. +Each `edi.configuration` record links a **trigger** (an +`edi.configuration.trigger` code) to a **snippet** (`snippet_do`) that is +executed every time the matching event fires on an exchange record. + +Built-in events fired by `EDIExchangeRecord` include: + +- `on_edi_exchange_done` — exchange processed successfully +- `on_edi_exchange_error` — exchange ended in error +- `on_edi_exchange_done_ack_received` — ACK file received +- `on_edi_exchange_done_ack_missing` — expected ACK not received +- `on_edi_exchange_done_ack_received_error` — ACK received with errors +- `on_edi_exchange__complete` — generic action completion (e.g. + `generate_complete`, `send_complete`), fired once on the exchange + record and once on its related record when present + +The snippet receives at least two variables in its evaluation context: + +- `conf` — the current `edi.configuration` record +- `record` — the target of the event (either the `edi.exchange.record` + itself or its related business record) + +Plus the standard `edi_exec_snippet_do` extras (`operation`, +`edi_action`, `old_value`, `vals`, ...). + +Two complementary lookup modes are available, and they can be combined +freely on the same flow. + +### Global event configurations + +Use this mode when you want a configuration to react to events on **any +business record** that travels through EDI, with no per-partner setup. + +Tick **Global Configuration** (`is_global`) on the `edi.configuration`. +When an event fires, the framework calls +`edi.configuration.edi_get_conf_global(exchange_record, trigger)` which +selects all active global configurations whose `trigger` matches the +event code, filtered by the originating exchange record: + +- **Exchange type** (`type_id`): must match the exchange record's type, + or be left empty to apply to every type +- **Backend** (`backend_id`): must match the exchange record's backend, + or be left empty to apply to every backend +- **Model** (`model_id` / `model_name`): must match the related record + model (e.g. `sale.order`, `account.move`), or be left empty to apply + to every model + +Empty values mean "applies to all". Inactive configurations and +non-global configurations are ignored. All matching configurations are +executed in sequence. + +Typical use cases: + +- Posting a generic chatter message on every exchange that ends in error +- Pushing a notification to an external system every time an ACK is + received for a given backend +- Logging extra audit information for every exchange of a given type + +### Partner-specific (relation-based) event configurations + +Use this mode when the reaction must depend on the partner (or any +other related record) involved in the exchange. + +In this case configurations are **not** marked as global. Instead, the +business record exposes an `edi_config_ids` relation (via +`edi.exchange.consumer.mixin._edi_config_field_relation`, which by +default returns `self.env["edi.configuration"]` and can be overridden, +for example to point at `self.partner_id.edi_config_ids`). When an +event fires on the business record (e.g. on create, on write, +on send-via-email/EDI), the framework calls +`edi_confs.edi_get_conf(trigger)` on that relation and runs the +matching snippets. + +Compared with global configurations: + +- **Discovery** comes from the record's own relation, not from a + database-wide search; this is the right place to model "this partner + wants this behaviour" rules +- **Filtering** is reduced to `trigger` and (optionally) `backend_id`, + since the recordset is already narrowed by the relation +- The same `snippet_do` API applies, so a snippet can be reused + verbatim between global and partner-specific configurations + +Typical use cases: + +- Sending a specific EDI flow only for a subset of partners +- Customising the document generation per customer (e.g. different + email template, different transport) +- Switching between EDI and email delivery based on partner + preferences + diff --git a/edi_core_oca/readme/DESCRIPTION.md b/edi_core_oca/readme/DESCRIPTION.md index 8cfcb472b..f9aee7085 100644 --- a/edi_core_oca/readme/DESCRIPTION.md +++ b/edi_core_oca/readme/DESCRIPTION.md @@ -8,4 +8,11 @@ Provides following models: 3. EDI Exchange Type, to define file types of exchange 4. EDI Exchange Record, to define a record exchanged between systems -Also define a mixin to be inherited by records that will generate EDIs +Also define a mixin to be inherited by records that will generate EDIs. + +In addition, the module ships an ``edi.configuration`` mechanism that lets +users react to EDI events declaratively, by writing small Python snippets +attached to event triggers. This can be used as a lightweight alternative +to component event listeners: configurations can react globally (on any +exchange) or be scoped to a specific partner (or any related record), +exchange type, backend and target model. See ``CONFIGURE.md`` for details. diff --git a/edi_core_oca/readme/HISTORY.md b/edi_core_oca/readme/HISTORY.md new file mode 100644 index 000000000..755aa4d94 --- /dev/null +++ b/edi_core_oca/readme/HISTORY.md @@ -0,0 +1,24 @@ +## 18.0.1.7.0 (2026-05-20) + +### Features + +- Introduce a new system for **global EDI events** based on ``edi.configuration`` + that can replace the use of component events. + + Any ``edi.configuration`` flagged as ``is_global`` is now picked up by + ``EDIExchangeRecord._trigger_edi_event`` and its ``snippet_do`` is executed + whenever the matching event fires (``done``, ``error``, ``ack_received``, + ``ack_missing``, ``ack_received_error``, ``_complete``, ...). + + Filtering is performed via the new ``edi.configuration.edi_get_conf_global`` + model method, which selects active global configurations matching the event + trigger code and, when set, the exchange type, the backend and the related + record model carried by the exchange record (empty values still mean "applies + to all"). This lets integrators subscribe to EDI events declaratively from + the UI instead of writing component listeners. + + Full test coverage is included for the dispatch on all ``notify_*`` events + (both on the exchange record and on the related record target) and for the + new filtering rules. + + Last but not lease: add minimal docs for edi.configuration. ([#global-edi-conf-events](https://github.com/OCA/edi-framework/issues/global-edi-conf-events)) diff --git a/edi_core_oca/readme/newsfragments/.gitkeep b/edi_core_oca/readme/newsfragments/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/edi_core_oca/static/description/index.html b/edi_core_oca/static/description/index.html index 0273741fe..de6475a29 100644 --- a/edi_core_oca/static/description/index.html +++ b/edi_core_oca/static/description/index.html @@ -372,7 +372,7 @@

EDI

!! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:c609033733302fa71a3c01c11e2729fd2b47ccde0b9a1d0619bed03cc26db4fe +!! source digest: sha256:27258fb23153f2660be19d7c76b04c4d09d35d1e240b779076c7f4a9fa66d9f2 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

Beta License: LGPL-3 OCA/edi-framework Translate me on Weblate Try me on Runboat

Base EDI backend.

@@ -384,7 +384,14 @@

EDI

  • EDI Exchange Type, to define file types of exchange
  • EDI Exchange Record, to define a record exchanged between systems
  • -

    Also define a mixin to be inherited by records that will generate EDIs

    +

    Also define a mixin to be inherited by records that will generate EDIs.

    +

    In addition, the module ships an edi.configuration mechanism that +lets users react to EDI events declaratively, by writing small Python +snippets attached to event triggers. This can be used as a lightweight +alternative to component event listeners: configurations can react +globally (on any exchange) or be scoped to a specific partner (or any +related record), exchange type, backend and target model. See +CONFIGURE.md for details.

    Table of contents

    +
    +

    Custom event handlers via edi.configuration

    +

    The framework can dispatch EDI lifecycle events to user-defined +configurations, providing a declarative alternative to component events. +Each edi.configuration record links a trigger (an +edi.configuration.trigger code) to a snippet (snippet_do) +that is executed every time the matching event fires on an exchange +record.

    +

    Built-in events fired by EDIExchangeRecord include:

    + +

    The snippet receives at least two variables in its evaluation context:

    + +

    Plus the standard edi_exec_snippet_do extras (operation, +edi_action, old_value, vals, …).

    +

    Two complementary lookup modes are available, and they can be combined +freely on the same flow.

    +
    +

    Global event configurations

    +

    Use this mode when you want a configuration to react to events on any +business record that travels through EDI, with no per-partner setup.

    +

    Tick Global Configuration (is_global) on the +edi.configuration. When an event fires, the framework calls +edi.configuration.edi_get_conf_global(exchange_record, trigger) +which selects all active global configurations whose trigger matches +the event code, filtered by the originating exchange record:

    + +

    Empty values mean “applies to all”. Inactive configurations and +non-global configurations are ignored. All matching configurations are +executed in sequence.

    +

    Typical use cases:

    + +
    +
    +

    Partner-specific (relation-based) event configurations

    +

    Use this mode when the reaction must depend on the partner (or any other +related record) involved in the exchange.

    +

    In this case configurations are not marked as global. Instead, the +business record exposes an edi_config_ids relation (via +edi.exchange.consumer.mixin._edi_config_field_relation, which by +default returns self.env["edi.configuration"] and can be overridden, +for example to point at self.partner_id.edi_config_ids). When an +event fires on the business record (e.g. on create, on write, on +send-via-email/EDI), the framework calls +edi_confs.edi_get_conf(trigger) on that relation and runs the +matching snippets.

    +

    Compared with global configurations:

    + +

    Typical use cases:

    + +
    +
    -

    Usage

    +

    Usage

    After certain operations or manual execution, Exchange records will be generated. This Exchange records might be input records or outputs records.

    The change of state can be manually executed by the system or be managed through by ir.cron.

    -

    Output Exchange records

    +

    Output Exchange records

    An output record is intended to be used for exchange information from Odoo to another system.

    The flow of an output record should be:

    @@ -509,7 +619,7 @@

    Output Exchange records

    -

    Input Exchange records

    +

    Input Exchange records

    An input record is intended to be used for exchange information another system to odoo.

    The flow of an input record should be:

    @@ -522,20 +632,51 @@

    Input Exchange records

    -

    Known issues / Roadmap

    +

    Known issues / Roadmap

    -

    14.0.1.0.0

    +

    14.0.1.0.0

    The module name has been changed from edi to edi_oca.

    -

    18.0.1.4.0

    +

    18.0.1.4.0

    Components dependancy has been removed and set on a new dependant module edi_component_oca. Module edi_oca has been_renamed to edi_core_oca.

    +
    +

    Changelog

    +
    +

    18.0.1.7.0 (2026-05-20)

    +
    +

    Features

    +
      +
    • Introduce a new system for global EDI events based on +edi.configuration that can replace the use of component events.

      +

      Any edi.configuration flagged as is_global is now picked up by +EDIExchangeRecord._trigger_edi_event and its snippet_do is +executed whenever the matching event fires (done, error, +ack_received, ack_missing, ack_received_error, +<action>_complete, …).

      +

      Filtering is performed via the new +edi.configuration.edi_get_conf_global model method, which selects +active global configurations matching the event trigger code and, when +set, the exchange type, the backend and the related record model +carried by the exchange record (empty values still mean “applies to +all”). This lets integrators subscribe to EDI events declaratively +from the UI instead of writing component listeners.

      +

      Full test coverage is included for the dispatch on all notify_* +events (both on the exchange record and on the related record target) +and for the new filtering rules.

      +

      Last but not lease: add minimal docs for edi.configuration. +(#global-edi-conf-events)

      +
    • +
    +
    +
    +
    -

    Bug Tracker

    +

    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 @@ -543,9 +684,9 @@

    Bug Tracker

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

    -

    Credits

    +

    Credits

    -

    Authors

    +

    Authors

    -

    Contributors

    +

    Contributors

    -

    Maintainers

    +

    Maintainers

    This module is maintained by the OCA.

    Odoo Community Association diff --git a/edi_core_oca/tests/test_backend_input.py b/edi_core_oca/tests/test_backend_input.py index 89d7fe13f..0ca5e2e5b 100644 --- a/edi_core_oca/tests/test_backend_input.py +++ b/edi_core_oca/tests/test_backend_input.py @@ -3,6 +3,7 @@ # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). from odoo_test_helper import FakeModelLoader +from psycopg2 import OperationalError from .common import EDIBackendCommonTestCase @@ -78,3 +79,12 @@ def test_receive_allow_empty_file_record(self): # Check the record self.assertEqual(self.record._get_file_content(), "") self.assertRecordValues(self.record, [{"edi_exchange_state": "input_received"}]) + + def test_receive_record_with_operational_error(self): + self.record.edi_exchange_state = "input_pending" + with self.assertRaises(OperationalError): + self.backend.with_context( + test_break_receive=OperationalError("SQL error") + ).exchange_receive(self.record) + self.assertRecordValues(self.record, [{"edi_exchange_state": "input_pending"}]) + self.assertFalse(self.record.exchange_error) diff --git a/edi_core_oca/tests/test_backend_output.py b/edi_core_oca/tests/test_backend_output.py index 69bd07fe8..2d5e49810 100644 --- a/edi_core_oca/tests/test_backend_output.py +++ b/edi_core_oca/tests/test_backend_output.py @@ -7,6 +7,7 @@ from freezegun import freeze_time from odoo_test_helper import FakeModelLoader +from psycopg2 import OperationalError from odoo import fields, tools from odoo.exceptions import UserError @@ -122,3 +123,13 @@ def test_send_not_generated_record(self): err.exception.args[0], "Record ID=%d has no file to send!" % record.id ) mocked.assert_not_called() + + def test_send_record_with_operational_error(self): + self.record.write({"edi_exchange_state": "output_pending"}) + self.record._set_file_content("TEST %d" % self.record.id) + with self.assertRaises(OperationalError): + self.backend.with_context( + test_break_send=OperationalError("SQL error") + ).exchange_send(self.record) + self.assertRecordValues(self.record, [{"edi_exchange_state": "output_pending"}]) + self.assertFalse(self.record.exchange_error) diff --git a/edi_core_oca/tests/test_backend_process.py b/edi_core_oca/tests/test_backend_process.py index 913abfb6d..de259115e 100644 --- a/edi_core_oca/tests/test_backend_process.py +++ b/edi_core_oca/tests/test_backend_process.py @@ -6,6 +6,7 @@ from freezegun import freeze_time from odoo_test_helper import FakeModelLoader +from psycopg2 import IntegrityError from odoo import fields from odoo.exceptions import UserError @@ -103,4 +104,13 @@ def test_process_outbound_record(self): with self.assertRaises(UserError): record.action_exchange_process() + def test_process_record_with_integrity_error(self): + self.record.write({"edi_exchange_state": "input_received"}) + with self.assertRaises(IntegrityError): + self.backend.with_context( + test_break_process=IntegrityError("SQL error") + ).exchange_process(self.record) + self.assertRecordValues(self.record, [{"edi_exchange_state": "input_received"}]) + self.assertFalse(self.record.exchange_error) + # TODO: test ack file are processed diff --git a/edi_core_oca/tests/test_edi_configuration.py b/edi_core_oca/tests/test_edi_configuration.py index 689a8f6c1..d624b697e 100644 --- a/edi_core_oca/tests/test_edi_configuration.py +++ b/edi_core_oca/tests/test_edi_configuration.py @@ -152,3 +152,256 @@ def test_edi_code_snippet(self): ) # Check the new vals after execution self.assertEqual(vals, expected_value) + + +class TestEDIConfigurationGlobalEvents(EDIBackendCommonTestCase): + """Test the global event dispatch via edi.configuration. + + `EDIExchangeRecord._trigger_edi_event` looks up all `edi.configuration` + records flagged as `is_global` and matching the event trigger code, + then executes their `snippet_do` against the target record. + These tests verify the dispatch happens for all `notify_*` events + and that the proper target (exchange record vs related record) + is passed to the snippet. + """ + + # Snippet appends a marker per call so we can verify multiple invocations + # against different targets within the same transaction. + _marker_snippet = ( + "conf.write({'description': (conf.description or '') + '|' + record._name})" + ) + + @classmethod + def setUpClass(cls): + super().setUpClass() + vals = { + "model": cls.partner._name, + "res_id": cls.partner.id, + } + cls.record = cls.backend.create_record("test_csv_output", vals) + cls.trigger_model = cls.env["edi.configuration.trigger"] + cls.conf_model = cls.env["edi.configuration"] + # Reuse existing data triggers when available, create the missing ones. + cls.trigger_done = cls.env.ref("edi_core_oca.edi_config_trigger_record_done") + cls.trigger_error = cls.env.ref("edi_core_oca.edi_config_trigger_record_error") + cls.trigger_ack_received = cls._get_or_create_trigger( + "on_edi_exchange_done_ack_received", "On ACK received" + ) + cls.trigger_ack_missing = cls._get_or_create_trigger( + "on_edi_exchange_done_ack_missing", "On ACK missing" + ) + cls.trigger_ack_received_error = cls._get_or_create_trigger( + "on_edi_exchange_done_ack_received_error", "On ACK received error" + ) + cls.trigger_generate_complete = cls._get_or_create_trigger( + "on_edi_exchange_generate_complete", "On generate complete" + ) + + @classmethod + def _get_or_create_trigger(cls, code, name): + trigger = cls.trigger_model.search([("code", "=", code)], limit=1) + if not trigger: + trigger = cls.trigger_model.create({"name": name, "code": code}) + return trigger + + def _make_conf(self, trigger, name, is_global=True, snippet=None, **overrides): + vals = { + "name": name, + "active": True, + "backend_id": self.backend.id, + "type_id": self.exchange_type_out.id, + "trigger_id": trigger.id, + "is_global": is_global, + "snippet_do": snippet or self._marker_snippet, + } + vals.update(overrides) + return self.conf_model.create(vals) + + def test_notify_done_triggers_global_conf(self): + conf = self._make_conf(self.trigger_done, "Global Done") + self.record._notify_done() + self.assertEqual(conf.description, f"|{self.record._name}") + + def test_notify_error_triggers_global_conf(self): + conf = self._make_conf(self.trigger_error, "Global Error") + self.record._notify_error("send_ko") + self.assertEqual(conf.description, f"|{self.record._name}") + + def test_notify_ack_received_triggers_global_conf(self): + conf = self._make_conf(self.trigger_ack_received, "Global ACK received") + self.record._notify_ack_received() + self.assertEqual(conf.description, f"|{self.record._name}") + + def test_notify_ack_missing_triggers_global_conf(self): + conf = self._make_conf(self.trigger_ack_missing, "Global ACK missing") + self.record._notify_ack_missing() + self.assertEqual(conf.description, f"|{self.record._name}") + + def test_notify_ack_received_error_triggers_global_conf(self): + conf = self._make_conf( + self.trigger_ack_received_error, "Global ACK received error" + ) + self.record._notify_ack_received_error() + self.assertEqual(conf.description, f"|{self.record._name}") + + def test_non_global_conf_is_ignored(self): + conf = self._make_conf(self.trigger_done, "Non Global Done", is_global=False) + self.record._notify_done() + self.assertFalse(conf.description) + + def test_inactive_global_conf_is_ignored(self): + conf = self._make_conf(self.trigger_done, "Inactive Global Done") + conf.active = False + self.record._notify_done() + self.assertFalse(conf.description) + + def test_notify_action_complete_dispatches_to_both_targets(self): + """`notify_action_complete` fires the event twice when the related + record exists: once with the exchange record as target, once with the + related record (partner here).""" + conf = self._make_conf( + self.trigger_generate_complete, "Global generate complete" + ) + # Sanity check: the exchange record has a related record. + self.assertTrue(self.record.related_record_exists) + self.record.notify_action_complete("generate") + # The snippet appended one marker per call: exchange record then partner. + self.assertEqual( + conf.description, + f"|{self.record._name}|{self.partner._name}", + ) + + def test_notify_action_complete_no_related_record(self): + """When no related record exists, the event fires only on the + exchange record itself.""" + conf = self._make_conf( + self.trigger_generate_complete, "Global generate complete - no related" + ) + # Create an exchange record with no related record. + orphan_record = self.backend.create_record( + "test_csv_output", {"model": False, "res_id": False} + ) + orphan_record.notify_action_complete("generate") + self.assertEqual(conf.description, f"|{orphan_record._name}") + + def test_snippet_receives_conf_and_record(self): + """The snippet eval context must expose both `conf` (the configuration) + and `record` (the target of the event).""" + snippet = ( + "conf.write({'description': 'conf=%s|record=%s' % " + "(conf.name, record.display_name)})" + ) + conf = self._make_conf(self.trigger_done, "Context check", snippet=snippet) + self.record._notify_done() + self.assertEqual( + conf.description, + f"conf={conf.name}|record={self.record.display_name}", + ) + + def test_multiple_global_confs_all_executed(self): + """All global confs matching the trigger are executed.""" + conf1 = self._make_conf(self.trigger_done, "Global Done 1") + conf2 = self._make_conf(self.trigger_done, "Global Done 2") + self.record._notify_done() + self.assertEqual(conf1.description, f"|{self.record._name}") + self.assertEqual(conf2.description, f"|{self.record._name}") + + # ------------------------------------------------------------------ + # Filtering tests for `edi_get_conf_global` + # ------------------------------------------------------------------ + def test_filter_by_type_mismatch(self): + """A conf bound to a different exchange type must not fire.""" + conf = self._make_conf( + self.trigger_done, + "Wrong type", + type_id=self.exchange_type_in.id, + ) + self.record._notify_done() + self.assertFalse(conf.description) + + def test_filter_by_type_empty_matches(self): + """A conf without a type matches any exchange record's type.""" + conf = self._make_conf(self.trigger_done, "No type", type_id=False) + self.record._notify_done() + self.assertEqual(conf.description, f"|{self.record._name}") + + def test_filter_by_backend_mismatch(self): + """A conf bound to a different backend must not fire.""" + other_backend = self.env["edi.backend"].create( + { + "name": "Other backend", + "backend_type_id": self.backend.backend_type_id.id, + } + ) + # `_constrains_backend` requires backend to be compatible with the type's + # backend if the type has one set. Detach the type from the conf to test + # only the backend filter. + conf = self._make_conf( + self.trigger_done, + "Wrong backend", + backend_id=other_backend.id, + type_id=False, + ) + self.record._notify_done() + self.assertFalse(conf.description) + + def test_filter_by_backend_empty_matches(self): + """A conf without a backend matches any exchange record's backend.""" + conf = self._make_conf( + self.trigger_done, + "No backend", + backend_id=False, + type_id=False, + ) + self.record._notify_done() + self.assertEqual(conf.description, f"|{self.record._name}") + + def test_filter_by_model_mismatch(self): + """A conf bound to a different model must not fire.""" + other_model = self.env["ir.model"]._get("res.users") + conf = self._make_conf( + self.trigger_done, + "Wrong model", + model_id=other_model.id, + ) + self.record._notify_done() + self.assertFalse(conf.description) + + def test_filter_by_model_match(self): + """A conf bound to the related record model fires.""" + partner_model = self.env["ir.model"]._get(self.partner._name) + conf = self._make_conf( + self.trigger_done, + "Matching model", + model_id=partner_model.id, + ) + self.record._notify_done() + self.assertEqual(conf.description, f"|{self.record._name}") + + def test_filter_by_model_orphan_record(self): + """A conf with a model is skipped on records with no related model.""" + partner_model = self.env["ir.model"]._get(self.partner._name) + conf_with_model = self._make_conf( + self.trigger_done, + "Model bound", + model_id=partner_model.id, + ) + conf_no_model = self._make_conf(self.trigger_done, "Model-less") + orphan_record = self.backend.create_record( + "test_csv_output", {"model": False, "res_id": False} + ) + orphan_record._notify_done() + self.assertFalse(conf_with_model.description) + self.assertEqual(conf_no_model.description, f"|{orphan_record._name}") + + def test_edi_get_conf_global_returns_only_matching(self): + """Direct check on the new helper method.""" + matching = self._make_conf(self.trigger_done, "Matching") + wrong_trigger = self._make_conf(self.trigger_error, "Wrong trigger") + non_global = self._make_conf(self.trigger_done, "Non global", is_global=False) + result = self.env["edi.configuration"].edi_get_conf_global( + self.record, self.trigger_done.code + ) + self.assertIn(matching, result) + self.assertNotIn(wrong_trigger, result) + self.assertNotIn(non_global, result) diff --git a/edi_core_oca/views/edi_configuration_views.xml b/edi_core_oca/views/edi_configuration_views.xml index 053db7764..674e88f8b 100644 --- a/edi_core_oca/views/edi_configuration_views.xml +++ b/edi_core_oca/views/edi_configuration_views.xml @@ -74,6 +74,7 @@ name="model_id" options="{'no_create': True, 'no_create_edit': True}" /> + diff --git a/edi_oca/i18n/edi_oca.pot b/edi_oca/i18n/edi_oca.pot index f3babc523..543fed99d 100644 --- a/edi_oca/i18n/edi_oca.pot +++ b/edi_oca/i18n/edi_oca.pot @@ -882,6 +882,11 @@ msgstr "" msgid "Generator" msgstr "" +#. module: edi_oca +#: model:ir.model.fields,field_description:edi_oca.field_edi_configuration__is_global +msgid "Global Configuration" +msgstr "" + #. module: edi_oca #: model_terms:ir.ui.view,arch_db:edi_oca.edi_exchange_record_view_search #: model_terms:ir.ui.view,arch_db:edi_oca.edi_exchange_type_rule_view_search @@ -946,6 +951,13 @@ msgstr "" msgid "If checked, some messages have a delivery error." msgstr "" +#. module: edi_oca +#: model:ir.model.fields,help:edi_oca.field_edi_configuration__is_global +msgid "" +"If checked, this configuration will be executed for all records, regardless " +"of the partner relation." +msgstr "" + #. module: edi_oca #: model:ir.model.fields,help:edi_oca.field_edi_exchange_type__exchange_filename_sequence_id msgid "" diff --git a/edi_oca/i18n/it.po b/edi_oca/i18n/it.po index 33faed5c2..d2cdce3ed 100644 --- a/edi_oca/i18n/it.po +++ b/edi_oca/i18n/it.po @@ -970,6 +970,11 @@ msgstr "Genera record di scambio" msgid "Generator" msgstr "Generatore" +#. module: edi_oca +#: model:ir.model.fields,field_description:edi_oca.field_edi_configuration__is_global +msgid "Global Configuration" +msgstr "" + #. module: edi_oca #: model_terms:ir.ui.view,arch_db:edi_oca.edi_exchange_record_view_search #: model_terms:ir.ui.view,arch_db:edi_oca.edi_exchange_type_rule_view_search @@ -1041,6 +1046,13 @@ msgstr "Se selezionata, nuovi messaggi richiedono attenzione." msgid "If checked, some messages have a delivery error." msgstr "Se selezionata, alcuni messaggi hanno un errore di consegna." +#. module: edi_oca +#: model:ir.model.fields,help:edi_oca.field_edi_configuration__is_global +msgid "" +"If checked, this configuration will be executed for all records, regardless " +"of the partner relation." +msgstr "" + #. module: edi_oca #: model:ir.model.fields,help:edi_oca.field_edi_exchange_type__exchange_filename_sequence_id msgid "" diff --git a/edi_purchase_oca/README.rst b/edi_purchase_oca/README.rst new file mode 100644 index 000000000..e35506069 --- /dev/null +++ b/edi_purchase_oca/README.rst @@ -0,0 +1,89 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +============ +EDI Purchase +============ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:35895d0baca724ca82a2652ae75fb72231316d24ea998ba0cfeeb49648bcfa83 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fedi--framework-lightgray.png?logo=github + :target: https://github.com/OCA/edi-framework/tree/18.0/edi_purchase_oca + :alt: OCA/edi-framework +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/edi-framework-18-0/edi-framework-18-0-edi_purchase_oca + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/edi-framework&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Handle purchase orders via EDI. + +This is a base module to plug purchase processes with the EDI framework. + +To handle inbound/outbound purchase orders, you need to create your own +integration modules on top of this base module. + +**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 +------- + +* ForgeFlow +* Camptocamp + +Contributors +------------ + +- Lois Rilo lois.rilo@forgeflow.com +- Simone Orsi simone.orsi@camptocamp.com +- Phan Hong Phuc +- Maksym Yankin maksym.yankin@camptocamp.com + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/edi-framework `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/edi_purchase_oca/__init__.py b/edi_purchase_oca/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/edi_purchase_oca/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/edi_purchase_oca/__manifest__.py b/edi_purchase_oca/__manifest__.py new file mode 100644 index 000000000..211961ea0 --- /dev/null +++ b/edi_purchase_oca/__manifest__.py @@ -0,0 +1,30 @@ +# Copyright 2022 ForgeFlow S.L. (https://www.forgeflow.com) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +{ + "name": "EDI Purchase", + "summary": """ + Define EDI Configuration for Purchase Orders""", + "version": "18.0.1.0.0", + "license": "LGPL-3", + "author": "ForgeFlow, Camptocamp, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/edi-framework", + "depends": [ + "purchase", + "edi_core_oca", + "edi_record_metadata_oca", + ], + "data": [ + # Data + "data/edi_configuration.xml", + # Views + "views/edi_exchange_record_views.xml", + "views/purchase_order_views.xml", + "views/res_partner_view.xml", + ], + "demo": [ + "demo/edi_backend.xml", + "demo/edi_exchange_type.xml", + "demo/edi_configuration.xml", + ], +} diff --git a/edi_purchase_oca/data/edi_configuration.xml b/edi_purchase_oca/data/edi_configuration.xml new file mode 100644 index 000000000..41417f92c --- /dev/null +++ b/edi_purchase_oca/data/edi_configuration.xml @@ -0,0 +1,13 @@ + + + + + On PO state change + on_edi_purchase_order_state_change + Trigger when a purchase order state changes + + + diff --git a/edi_purchase_oca/demo/edi_backend.xml b/edi_purchase_oca/demo/edi_backend.xml new file mode 100644 index 000000000..3410efd87 --- /dev/null +++ b/edi_purchase_oca/demo/edi_backend.xml @@ -0,0 +1,11 @@ + + + + Purchase DEMO + purchase_demo + + + purchase DEMO + + + diff --git a/edi_purchase_oca/demo/edi_configuration.xml b/edi_purchase_oca/demo/edi_configuration.xml new file mode 100644 index 000000000..6f614a31f --- /dev/null +++ b/edi_purchase_oca/demo/edi_configuration.xml @@ -0,0 +1,36 @@ + + + + Demo Purchase Order - order confirmed + Show case how you can send out an order automatically + + + + + +# ('draft', 'RFQ'), +# ('sent', 'RFQ Sent'), +# ('to approve', 'To Approve'), +# ('purchase', 'Purchase Order'), +# ('cancel', 'Cancelled') +if record.state == 'purchase': + record._edi_send_via_edi(conf.type_id) + + + + Demo Purchase Order - order cancelled + Show case how you can send out an order automatically + + + + + +if record.state == 'cancel': + record._edi_send_via_edi(conf.type_id) + + + diff --git a/edi_purchase_oca/demo/edi_exchange_type.xml b/edi_purchase_oca/demo/edi_exchange_type.xml new file mode 100644 index 000000000..161349aae --- /dev/null +++ b/edi_purchase_oca/demo/edi_exchange_type.xml @@ -0,0 +1,12 @@ + + + + + + Demo Purchase Order out + demo_PurchaseOrder_out + output + {record_name}-{type.code}-{dt} + xml + + diff --git a/edi_purchase_oca/i18n/edi_purchase_oca.pot b/edi_purchase_oca/i18n/edi_purchase_oca.pot new file mode 100644 index 000000000..249b4e6aa --- /dev/null +++ b/edi_purchase_oca/i18n/edi_purchase_oca.pot @@ -0,0 +1,153 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * edi_purchase_oca +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: edi_purchase_oca +#: model_terms:ir.ui.view,arch_db:edi_purchase_oca.purchase_order_form +msgid "EDI" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model,name:edi_purchase_oca.model_res_partner +msgid "Contact" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order__edi_disable_auto +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order_line__edi_disable_auto +msgid "Disable auto" +msgstr "" + +#. module: edi_purchase_oca +#: model_terms:ir.ui.view,arch_db:edi_purchase_oca.purchase_order_form +msgid "Disable automated actions" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.ui.menu,name:edi_purchase_oca.menu_purchase_edi_root +#: model_terms:ir.ui.view,arch_db:edi_purchase_oca.purchase_order_form +msgid "EDI" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order_line__edi_id +msgid "EDI ID" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order__origin_edi_endpoint_id +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order_line__origin_edi_endpoint_id +msgid "EDI origin endpoint" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order__origin_exchange_type_id +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order_line__origin_exchange_type_id +msgid "EDI origin exchange type" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order__origin_exchange_record_id +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order_line__origin_exchange_record_id +msgid "EDI origin record" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_res_partner__edi_purchase_conf_ids +#: model:ir.model.fields,field_description:edi_purchase_oca.field_res_users__edi_purchase_conf_ids +msgid "EDI purchase configuration" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,help:edi_purchase_oca.field_purchase_order__origin_exchange_record_id +#: model:ir.model.fields,help:edi_purchase_oca.field_purchase_order_line__origin_exchange_record_id +msgid "EDI record that originated this document." +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order__edi_config +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order_line__edi_config +msgid "Edi Config" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order_line__edi_exchange_ready +msgid "Edi Exchange Ready" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order__edi_has_form_config +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order_line__edi_has_form_config +msgid "Edi Has Form Config" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order__exchange_record_ids +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order_line__exchange_record_ids +msgid "Exchange Record" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order__exchange_record_count +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order_line__exchange_record_count +msgid "Exchange Record Count" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order__exchange_related_record_ids +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order_line__exchange_related_record_ids +msgid "Exchange Related Record" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.ui.menu,name:edi_purchase_oca.menu_purchase_edi_exchange_record +msgid "Exchanges" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,help:edi_purchase_oca.field_purchase_order_line__edi_id +msgid "Internal or external identifier for records." +msgstr "" + +#. module: edi_purchase_oca +#: model_terms:ir.ui.view,arch_db:edi_purchase_oca.view_partner_form +msgid "Purchase" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model,name:edi_purchase_oca.model_purchase_order +msgid "Purchase Order" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.actions.act_window,name:edi_purchase_oca.act_open_edi_exchange_record_purchase_order_view +msgid "Purchase Order Exchange Records" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model,name:edi_purchase_oca.model_purchase_order_line +msgid "Purchase Order Line" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,help:edi_purchase_oca.field_purchase_order__origin_edi_endpoint_id +#: model:ir.model.fields,help:edi_purchase_oca.field_purchase_order_line__origin_edi_endpoint_id +msgid "Record generated via this endpoint" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,help:edi_purchase_oca.field_purchase_order__edi_disable_auto +#: model:ir.model.fields,help:edi_purchase_oca.field_purchase_order_line__edi_disable_auto +msgid "When marked, EDI automatic processing will be avoided" +msgstr "" diff --git a/edi_purchase_oca/i18n/es.po b/edi_purchase_oca/i18n/es.po new file mode 100644 index 000000000..b10ade041 --- /dev/null +++ b/edi_purchase_oca/i18n/es.po @@ -0,0 +1,112 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * edi_purchase_oca +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2023-11-25 11:34+0000\n" +"Last-Translator: Ivorra78 \n" +"Language-Team: none\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.17\n" + +#. module: edi_purchase_oca +#: model_terms:ir.ui.view,arch_db:edi_purchase_oca.purchase_order_form +msgid "EDI" +msgstr "EDI" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order__disable_edi_auto +msgid "Disable auto" +msgstr "Deshabilitar auto" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order__display_name +msgid "Display Name" +msgstr "Mostrar Nombre" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order__origin_edi_endpoint_id +msgid "EDI origin endpoint" +msgstr "Punto final de origen EDI" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order__origin_exchange_type_id +msgid "EDI origin exchange type" +msgstr "Tipo de intercambio de origen EDI" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order__origin_exchange_record_id +msgid "EDI origin record" +msgstr "Registro de origen EDI" + +#. module: edi_purchase_oca +#: model:ir.model.fields,help:edi_purchase_oca.field_purchase_order__origin_exchange_record_id +msgid "EDI record that originated this document." +msgstr "Registro EDI que originó este documento." + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order__edi_config +msgid "Edi Config" +msgstr "Configuración Edi" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order__edi_has_form_config +msgid "Edi Has Form Config" +msgstr "Edi Tiene Formulario Config" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order__exchange_record_ids +msgid "Exchange Record" +msgstr "Registro de Intercambio" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order__exchange_record_count +msgid "Exchange Record Count" +msgstr "Recuento de Registros de Intercambio" + +#. module: edi_purchase_oca +#: model:ir.ui.menu,name:edi_purchase_oca.menu_purchase_edi_root +msgid "Exchange records" +msgstr "Registros de intercambio" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order__id +msgid "ID" +msgstr "ID" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order____last_update +msgid "Last Modified on" +msgstr "Última actualización el" + +#. module: edi_purchase_oca +#: model:ir.model,name:edi_purchase_oca.model_purchase_order +msgid "Purchase Order" +msgstr "Orden de Compra" + +#. module: edi_purchase_oca +#: model:ir.actions.act_window,name:edi_purchase_oca.act_open_edi_exchange_record_purchase_order_view +msgid "Purchase Order Exchange Record" +msgstr "Registro de Intercambio de Órdenes de Compra" + +#. module: edi_purchase_oca +#: model:ir.ui.menu,name:edi_purchase_oca.menu_purchase_edi_exchange_record +msgid "Purchase Orders" +msgstr "Órdenes de Compra" + +#. module: edi_purchase_oca +#: model:ir.model.fields,help:edi_purchase_oca.field_purchase_order__origin_edi_endpoint_id +msgid "Record generated via this endpoint" +msgstr "Registro generado a través de este punto final" + +#. module: edi_purchase_oca +#: model:ir.model.fields,help:edi_purchase_oca.field_purchase_order__disable_edi_auto +msgid "When marked, EDI automatic processing will be avoided" +msgstr "Si se marca, se evitará el procesamiento automático EDI" diff --git a/edi_purchase_oca/i18n/it.po b/edi_purchase_oca/i18n/it.po new file mode 100644 index 000000000..dde2677a8 --- /dev/null +++ b/edi_purchase_oca/i18n/it.po @@ -0,0 +1,154 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * edi_purchase_oca +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" + +#. module: edi_purchase_oca +#: model_terms:ir.ui.view,arch_db:edi_purchase_oca.purchase_order_form +msgid "EDI" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model,name:edi_purchase_oca.model_res_partner +msgid "Contact" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order__edi_disable_auto +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order_line__edi_disable_auto +msgid "Disable auto" +msgstr "" + +#. module: edi_purchase_oca +#: model_terms:ir.ui.view,arch_db:edi_purchase_oca.purchase_order_form +msgid "Disable automated actions" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.ui.menu,name:edi_purchase_oca.menu_purchase_edi_root +#: model_terms:ir.ui.view,arch_db:edi_purchase_oca.purchase_order_form +msgid "EDI" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order_line__edi_id +msgid "EDI ID" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order__origin_edi_endpoint_id +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order_line__origin_edi_endpoint_id +msgid "EDI origin endpoint" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order__origin_exchange_type_id +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order_line__origin_exchange_type_id +msgid "EDI origin exchange type" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order__origin_exchange_record_id +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order_line__origin_exchange_record_id +msgid "EDI origin record" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_res_partner__edi_purchase_conf_ids +#: model:ir.model.fields,field_description:edi_purchase_oca.field_res_users__edi_purchase_conf_ids +msgid "EDI purchase configuration" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,help:edi_purchase_oca.field_purchase_order__origin_exchange_record_id +#: model:ir.model.fields,help:edi_purchase_oca.field_purchase_order_line__origin_exchange_record_id +msgid "EDI record that originated this document." +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order__edi_config +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order_line__edi_config +msgid "Edi Config" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order_line__edi_exchange_ready +msgid "Edi Exchange Ready" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order__edi_has_form_config +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order_line__edi_has_form_config +msgid "Edi Has Form Config" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order__exchange_record_ids +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order_line__exchange_record_ids +msgid "Exchange Record" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order__exchange_record_count +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order_line__exchange_record_count +msgid "Exchange Record Count" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order__exchange_related_record_ids +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order_line__exchange_related_record_ids +msgid "Exchange Related Record" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.ui.menu,name:edi_purchase_oca.menu_purchase_edi_exchange_record +msgid "Exchanges" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,help:edi_purchase_oca.field_purchase_order_line__edi_id +msgid "Internal or external identifier for records." +msgstr "" + +#. module: edi_purchase_oca +#: model_terms:ir.ui.view,arch_db:edi_purchase_oca.view_partner_form +msgid "Purchase" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model,name:edi_purchase_oca.model_purchase_order +msgid "Purchase Order" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.actions.act_window,name:edi_purchase_oca.act_open_edi_exchange_record_purchase_order_view +msgid "Purchase Order Exchange Records" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model,name:edi_purchase_oca.model_purchase_order_line +msgid "Purchase Order Line" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,help:edi_purchase_oca.field_purchase_order__origin_edi_endpoint_id +#: model:ir.model.fields,help:edi_purchase_oca.field_purchase_order_line__origin_edi_endpoint_id +msgid "Record generated via this endpoint" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,help:edi_purchase_oca.field_purchase_order__edi_disable_auto +#: model:ir.model.fields,help:edi_purchase_oca.field_purchase_order_line__edi_disable_auto +msgid "When marked, EDI automatic processing will be avoided" +msgstr "" diff --git a/edi_purchase_oca/models/__init__.py b/edi_purchase_oca/models/__init__.py new file mode 100644 index 000000000..7b66e4fca --- /dev/null +++ b/edi_purchase_oca/models/__init__.py @@ -0,0 +1,3 @@ +from . import purchase_order_line +from . import purchase_order +from . import res_partner diff --git a/edi_purchase_oca/models/purchase_order.py b/edi_purchase_oca/models/purchase_order.py new file mode 100644 index 000000000..f066ce6c7 --- /dev/null +++ b/edi_purchase_oca/models/purchase_order.py @@ -0,0 +1,27 @@ +# Copyright 2022 ForgeFlow S.L. (https://www.forgeflow.com) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import models + + +class PurchaseOrder(models.Model): + _name = "purchase.order" + _inherit = [ + "purchase.order", + "edi.exchange.consumer.mixin", + ] + + def _edi_config_field_relation(self): + return self.partner_id.edi_purchase_conf_ids + + # edi_record_metadata api + def _edi_get_metadata_to_store(self, orig_vals): + data = super()._edi_get_metadata_to_store(orig_vals) + line_vals_by_edi_id = {} + for line_vals in orig_vals.get("order_line", []): + vals = line_vals[-1] + edi_id = vals.get("edi_id") + if edi_id: + line_vals_by_edi_id[edi_id] = vals + data.update({"orig_values": {"lines": line_vals_by_edi_id}}) + return data diff --git a/edi_purchase_oca/models/purchase_order_line.py b/edi_purchase_oca/models/purchase_order_line.py new file mode 100644 index 000000000..9cec4f939 --- /dev/null +++ b/edi_purchase_oca/models/purchase_order_line.py @@ -0,0 +1,36 @@ +# Copyright 2026 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class PurchaseOrderLine(models.Model): + _name = "purchase.order.line" + _inherit = [ + "purchase.order.line", + "edi.exchange.consumer.mixin", + "edi.id.mixin", + ] + + edi_disable_auto = fields.Boolean(related="order_id.edi_disable_auto") + edi_exchange_ready = fields.Boolean(compute="_compute_edi_exchange_ready") + + @api.depends() + def _compute_edi_exchange_ready(self): + for rec in self: + rec.edi_exchange_ready = rec._edi_exchange_ready() + + def _edi_exchange_ready(self): + # Only product lines are eligible for EDI processing + # sections/notes and downpayment lines should be ignored + return not self.display_type and not self.is_downpayment + + @api.model_create_multi + def create(self, vals_list): + # Set default origin if not passed + for vals in vals_list: + orig_id = vals.get("origin_exchange_record_id") + if not orig_id and "order_id" in vals: + order = self.env["purchase.order"].browse(vals["order_id"]) + vals["origin_exchange_record_id"] = order.origin_exchange_record_id.id + return super().create(vals_list) diff --git a/edi_purchase_oca/models/res_partner.py b/edi_purchase_oca/models/res_partner.py new file mode 100644 index 000000000..7c38d54b4 --- /dev/null +++ b/edi_purchase_oca/models/res_partner.py @@ -0,0 +1,18 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo import fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + edi_purchase_conf_ids = fields.Many2many( + string="EDI purchase configuration", + comodel_name="edi.configuration", + relation="res_partner_edi_purchase_configuration_rel", + column1="partner_id", + column2="conf_id", + domain=[("model_name", "=", "purchase.order")], + ) diff --git a/edi_purchase_oca/pyproject.toml b/edi_purchase_oca/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/edi_purchase_oca/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/edi_purchase_oca/readme/CONTRIBUTORS.md b/edi_purchase_oca/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..1ea50d1f7 --- /dev/null +++ b/edi_purchase_oca/readme/CONTRIBUTORS.md @@ -0,0 +1,4 @@ +* Lois Rilo +* Simone Orsi +* Phan Hong Phuc \<\> +* Maksym Yankin \ No newline at end of file diff --git a/edi_purchase_oca/readme/DESCRIPTION.md b/edi_purchase_oca/readme/DESCRIPTION.md new file mode 100644 index 000000000..0ea465878 --- /dev/null +++ b/edi_purchase_oca/readme/DESCRIPTION.md @@ -0,0 +1,6 @@ +Handle purchase orders via EDI. + +This is a base module to plug purchase processes with the EDI framework. + +To handle inbound/outbound purchase orders, you need to create your own +integration modules on top of this base module. diff --git a/edi_purchase_oca/static/description/icon.png b/edi_purchase_oca/static/description/icon.png new file mode 100644 index 000000000..a79752645 Binary files /dev/null and b/edi_purchase_oca/static/description/icon.png differ diff --git a/edi_purchase_oca/static/description/index.html b/edi_purchase_oca/static/description/index.html new file mode 100644 index 000000000..228082825 --- /dev/null +++ b/edi_purchase_oca/static/description/index.html @@ -0,0 +1,436 @@ + + + + + +README.rst + + + +
    + + + +Odoo Community Association + +
    +

    EDI Purchase

    + +

    Beta License: LGPL-3 OCA/edi-framework Translate me on Weblate Try me on Runboat

    +

    Handle purchase orders via EDI.

    +

    This is a base module to plug purchase processes with the EDI framework.

    +

    To handle inbound/outbound purchase orders, you need to create your own +integration modules on top of this base module.

    +

    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

    +
      +
    • ForgeFlow
    • +
    • Camptocamp
    • +
    +
    + +
    +

    Maintainers

    +

    This module is maintained by the OCA.

    + +Odoo Community Association + +

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

    +

    This module is part of the OCA/edi-framework project on GitHub.

    +

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

    +
    +
    +
    +
    + + diff --git a/edi_purchase_oca/tests/__init__.py b/edi_purchase_oca/tests/__init__.py new file mode 100644 index 000000000..6bdd2b970 --- /dev/null +++ b/edi_purchase_oca/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_generate +from . import test_order diff --git a/edi_purchase_oca/tests/common.py b/edi_purchase_oca/tests/common.py new file mode 100644 index 000000000..80d99c031 --- /dev/null +++ b/edi_purchase_oca/tests/common.py @@ -0,0 +1,82 @@ +# Copyright 2026 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields +from odoo.fields import Command + +from odoo.addons.edi_core_oca.tests.common import EDIBackendTestMixin + + +class PurchaseEDIBackendTestMixin(EDIBackendTestMixin): + @classmethod + def _get_backend_type(cls): + backend_type = cls.env["edi.backend.type"].search( + [("code", "=", "purchase_demo")], limit=1 + ) + if backend_type: + return backend_type + return cls.env["edi.backend.type"].create( + { + "name": "Purchase DEMO", + "code": "purchase_demo", + } + ) + + @classmethod + def _get_backend(cls): + backend_type = cls._get_backend_type() + backend = cls.env["edi.backend"].search( + [("backend_type_id", "=", backend_type.id)], limit=1 + ) + if backend: + return backend + return cls.env["edi.backend"].create( + { + "name": "purchase DEMO", + "backend_type_id": backend_type.id, + } + ) + + @classmethod + def _create_exchange_type(cls, **kw): + model = cls.env["edi.exchange.type"] + code = kw.get("code") + if code: + exchange_type = model.search( + [("code", "=", code), ("backend_id", "=", cls.backend.id)], limit=1 + ) + if exchange_type: + return exchange_type + return super()._create_exchange_type(**kw) + + +class OrderMixin: + @classmethod + def _create_purchase_order(cls, **kw): + model = cls.env["purchase.order"] + vals = { + "partner_id": cls.vendor.id, + "user_id": cls.env.ref("base.user_admin").id, + "date_planned": fields.Datetime.now(), + } + vals.update(kw) + if hasattr(model, "play_onchanges"): + po_vals = model.play_onchanges(vals, []) + else: + po_vals = vals.copy() + if "order_line" in vals: + po_vals["order_line"] = [Command.create(x) for x in vals["order_line"]] + return model.create(po_vals) + + @classmethod + def _setup_order_records(cls): + cls.vendor = cls.env["res.partner"].create( + {"name": "ACME inc", "country_id": cls.env.company.country_id.id} + ) + cls.product = cls.env["product.product"].create( + { + "name": "Product 1", + "default_code": "1234567", + "purchase_ok": True, + } + ) diff --git a/edi_purchase_oca/tests/test_generate.py b/edi_purchase_oca/tests/test_generate.py new file mode 100644 index 000000000..0f814b911 --- /dev/null +++ b/edi_purchase_oca/tests/test_generate.py @@ -0,0 +1,96 @@ +# Copyright 2026 Camptocamp SA +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.tests.common import TransactionCase + +from .common import OrderMixin, PurchaseEDIBackendTestMixin + + +class TestGenerateViaConf(TransactionCase, PurchaseEDIBackendTestMixin, OrderMixin): + """Verify that purchase EDI generation is driven by ``edi.configuration``. + + No component / no fake handler: we simply assert that the snippets bound + to the partner via ``partner_id.edi_purchase_conf_ids`` are executed by + the state-change event dispatched by ``edi.exchange.consumer.mixin``. + + Each snippet writes a marker on ``conf.description`` so we can verify + which configurations actually ran. + """ + + # Snippet writes the order's state on the conf description if it matches + # the expected target state. + _snippet_tpl = ( + "if record.state == '{state}':\n" + " conf.write({{'description': " + "(conf.description or '') + '|' + record.state}})" + ) + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._setup_env() + cls._setup_records() + + cls.exc_type = cls._create_exchange_type( + name="Demo Purchase Order out", + code="demo_PurchaseOrder_out", + direction="output", + exchange_filename_pattern="{record_name}-{type.code}-{dt}", + exchange_file_ext="xml", + ) + cls.state_change_trigger = cls.env.ref( + "edi_purchase_oca.edi_conf_trigger_purchase_order_state_change" + ) + purchase_model_id = cls.env["ir.model"]._get_id("purchase.order") + cls.edi_conf_confirmed = cls.env["edi.configuration"].create( + { + "name": "Demo Purchase Order - order confirmed", + "type_id": cls.exc_type.id, + "backend_id": cls.backend.id, + "model_id": purchase_model_id, + "trigger_id": cls.state_change_trigger.id, + "snippet_do": cls._snippet_tpl.format(state="purchase"), + } + ) + cls.edi_conf_cancelled = cls.env["edi.configuration"].create( + { + "name": "Demo Purchase Order - order cancelled", + "type_id": cls.exc_type.id, + "backend_id": cls.backend.id, + "model_id": purchase_model_id, + "trigger_id": cls.state_change_trigger.id, + "snippet_do": cls._snippet_tpl.format(state="cancel"), + } + ) + cls._setup_order_records() + + def test_new_order_no_conf_no_output(self): + # No conf linked to the vendor -> no snippet executed. + order = self._create_purchase_order() + order.button_confirm() + self.assertFalse(self.edi_conf_confirmed.description) + self.assertFalse(self.edi_conf_cancelled.description) + + def test_new_order_1conf_output(self): + self.vendor.edi_purchase_conf_ids = self.edi_conf_confirmed + order = self._create_purchase_order() + self.assertFalse(self.edi_conf_confirmed.description) + order.button_confirm() + self.assertEqual(self.edi_conf_confirmed.description, "|purchase") + # The cancelled conf is not even attached to the vendor. + self.assertFalse(self.edi_conf_cancelled.description) + + def test_new_order_2conf_output(self): + self.vendor.edi_purchase_conf_ids = ( + self.edi_conf_confirmed | self.edi_conf_cancelled + ) + order = self._create_purchase_order() + # Confirm -> only the "confirmed" snippet matches + order.button_confirm() + self.assertEqual(self.edi_conf_confirmed.description, "|purchase") + self.assertFalse(self.edi_conf_cancelled.description) + # Cancel -> the "cancelled" snippet matches + order.button_cancel() + self.assertEqual(self.edi_conf_confirmed.description, "|purchase") + self.assertEqual(self.edi_conf_cancelled.description, "|cancel") diff --git a/edi_purchase_oca/tests/test_order.py b/edi_purchase_oca/tests/test_order.py new file mode 100644 index 000000000..4fc5ad212 --- /dev/null +++ b/edi_purchase_oca/tests/test_order.py @@ -0,0 +1,70 @@ +# Copyright 2026 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.tests.common import TransactionCase + +from .common import OrderMixin, PurchaseEDIBackendTestMixin + + +class TestOrder(TransactionCase, PurchaseEDIBackendTestMixin, OrderMixin): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, edi_framework_action=True)) + cls._setup_records() + cls.exchange_type_in.exchange_filename_pattern = "{record.id}-{type.code}-{dt}" + cls.exc_record_in = cls.backend.create_record( + cls.exchange_type_in.code, {"edi_exchange_state": "input_received"} + ) + cls._setup_order_records() + order_vals = { + "order_line": [ + { + "product_id": cls.product.id, + "product_qty": 10, + "price_unit": 100.0, + } + ], + } + cls.order = cls._create_purchase_order( + origin_exchange_record_id=cls.exc_record_in.id, + **order_vals, + ) + + def test_line_origin(self): + order = self.order + self.assertEqual(order.origin_exchange_record_id, self.exc_record_in) + lines = order.order_line + self.env["purchase.order.line"].create( + [ + { + "order_id": order.id, + "product_id": self.product.id, + "product_qty": 20, + "price_unit": 100.0, + "edi_id": 2000, + }, + { + "order_id": order.id, + "product_id": self.product.id, + "product_qty": 30, + "price_unit": 100.0, + "edi_id": 3000, + }, + ] + ) + order.invalidate_recordset() + new_line1, new_line2 = order.order_line - lines + self.assertEqual(new_line1.origin_exchange_record_id, self.exc_record_in) + self.assertEqual(new_line2.origin_exchange_record_id, self.exc_record_in) + + def test_line_exchange_ready(self): + line_model = self.env["purchase.order.line"] + + regular_line = line_model.new({"product_id": self.product.id}) + section_line = line_model.new({"display_type": "line_section"}) + downpayment_line = line_model.new({"is_downpayment": True}) + + self.assertTrue(regular_line.edi_exchange_ready) + self.assertFalse(section_line.edi_exchange_ready) + self.assertFalse(downpayment_line.edi_exchange_ready) diff --git a/edi_purchase_oca/views/edi_exchange_record_views.xml b/edi_purchase_oca/views/edi_exchange_record_views.xml new file mode 100644 index 000000000..5b3dbeb92 --- /dev/null +++ b/edi_purchase_oca/views/edi_exchange_record_views.xml @@ -0,0 +1,29 @@ + + + + + Purchase Order Exchange Records + ir.actions.act_window + edi.exchange.record + list,form + [('model', '=', 'purchase.order')] + {} + + + + diff --git a/edi_purchase_oca/views/purchase_order_views.xml b/edi_purchase_oca/views/purchase_order_views.xml new file mode 100644 index 000000000..4c97b26e7 --- /dev/null +++ b/edi_purchase_oca/views/purchase_order_views.xml @@ -0,0 +1,52 @@ + + + + + purchase.order + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/edi_purchase_oca/views/res_partner_view.xml b/edi_purchase_oca/views/res_partner_view.xml new file mode 100644 index 000000000..4985d326e --- /dev/null +++ b/edi_purchase_oca/views/res_partner_view.xml @@ -0,0 +1,21 @@ + + + + res.partner + + + + + + + + + + + + + + + + + diff --git a/edi_purchase_ubl_output_oca/README.rst b/edi_purchase_ubl_output_oca/README.rst new file mode 100644 index 000000000..022885c57 --- /dev/null +++ b/edi_purchase_ubl_output_oca/README.rst @@ -0,0 +1,97 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +================ +EDI UBL Purchase +================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:64740d2084509ea5c625aeb34e7e20f2a11ffa3a8e3cb2e13c107092a4b438b2 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/license-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%2Fedi-lightgray.png?logo=github + :target: https://github.com/OCA/edi/tree/14.0/edi_purchase_ubl_output_oca + :alt: OCA/edi +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/edi-14-0/edi-14-0-edi_purchase_ubl_output_oca + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/edi&target_branch=14.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Handle purchase exchanges with the EDI framework. + +This module is mostly a glue module for `purchase_order_ubl` with `edi_oca`. + +Allows you to generate and send purchase orders as UBL XML files. + +.. 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 +~~~~~~~ + +* Camptocamp + +Contributors +~~~~~~~~~~~~ + +* Simone Orsi + +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-simahawk| image:: https://github.com/simahawk.png?size=40px + :target: https://github.com/simahawk + :alt: simahawk + +Current `maintainer `__: + +|maintainer-simahawk| + +This module is part of the `OCA/edi `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/edi_purchase_ubl_output_oca/__init__.py b/edi_purchase_ubl_output_oca/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/edi_purchase_ubl_output_oca/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/edi_purchase_ubl_output_oca/__manifest__.py b/edi_purchase_ubl_output_oca/__manifest__.py new file mode 100644 index 000000000..c9fcbbc7e --- /dev/null +++ b/edi_purchase_ubl_output_oca/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2021 Camptocamp SA +# @author: Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "EDI UBL Purchase", + "summary": """Handle outbound exchanges for purchases.""", + "version": "18.0.1.0.0", + "development_status": "Alpha", + "license": "AGPL-3", + "website": "https://github.com/OCA/edi-framework", + "author": "Camptocamp, Odoo Community Association (OCA)", + "maintainers": ["simahawk"], + "depends": ["edi_purchase_oca", "edi_ubl_oca", "purchase_order_ubl"], + "demo": [ + "demo/edi_exchange_type.xml", + ], +} diff --git a/edi_purchase_ubl_output_oca/demo/edi_exchange_type.xml b/edi_purchase_ubl_output_oca/demo/edi_exchange_type.xml new file mode 100644 index 000000000..8d8cca6d1 --- /dev/null +++ b/edi_purchase_ubl_output_oca/demo/edi_exchange_type.xml @@ -0,0 +1,15 @@ + + + + + Demo UBL PO out + demo_UBL_PO_out + output + {record_name}-{type.code}-{dt} + xml + + + diff --git a/edi_purchase_ubl_output_oca/i18n/edi_purchase_ubl_output_oca.pot b/edi_purchase_ubl_output_oca/i18n/edi_purchase_ubl_output_oca.pot new file mode 100644 index 000000000..4d8b20f91 --- /dev/null +++ b/edi_purchase_ubl_output_oca/i18n/edi_purchase_ubl_output_oca.pot @@ -0,0 +1,13 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" diff --git a/edi_purchase_ubl_output_oca/i18n/it.po b/edi_purchase_ubl_output_oca/i18n/it.po new file mode 100644 index 000000000..9ce4346f6 --- /dev/null +++ b/edi_purchase_ubl_output_oca/i18n/it.po @@ -0,0 +1,14 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" diff --git a/edi_purchase_ubl_output_oca/models/__init__.py b/edi_purchase_ubl_output_oca/models/__init__.py new file mode 100644 index 000000000..f839b0d3b --- /dev/null +++ b/edi_purchase_ubl_output_oca/models/__init__.py @@ -0,0 +1 @@ +from . import generate diff --git a/edi_purchase_ubl_output_oca/models/generate.py b/edi_purchase_ubl_output_oca/models/generate.py new file mode 100644 index 000000000..8ef69669c --- /dev/null +++ b/edi_purchase_ubl_output_oca/models/generate.py @@ -0,0 +1,26 @@ +# Copyright 2021 Camptocamp SA +# @author: Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models + + +class EDIExchangePOGenerate(models.AbstractModel): + """Generate purchase orders.""" + + _description = "UBL output generator for purchase orders" + + _name = "edi.output.ubl.purchase.order" + _inherit = "edi.oca.handler.generate" + + def generate(self, exchange_record): + return self._generate_ubl_xml(exchange_record) + + def _generate_ubl_xml(self, exchange_record): + order = exchange_record.record + doc_type = order.get_ubl_purchase_order_doc_type() + if not doc_type: + raise NotImplementedError("TODO: handle no doc type") + version = order.get_ubl_version() + xml_string = order.generate_ubl_xml_string(doc_type, version=version) + return xml_string diff --git a/edi_purchase_ubl_output_oca/pyproject.toml b/edi_purchase_ubl_output_oca/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/edi_purchase_ubl_output_oca/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/edi_purchase_ubl_output_oca/readme/CONFIGURATION.rst b/edi_purchase_ubl_output_oca/readme/CONFIGURATION.rst new file mode 100644 index 000000000..a02530592 --- /dev/null +++ b/edi_purchase_ubl_output_oca/readme/CONFIGURATION.rst @@ -0,0 +1,20 @@ +On your exchange type configured for UBL outbound exchanges +select "UBL output generator for purchase orders" as "Generator". + +Example of full flow configuration +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. Create an exchange type for UBL output, with "UBL output generator for purchase orders" as generator. +2. Create an `edi.configuration` for purchase orders with + + a. the exchange type created at step 1, + b. the model `purchase.order`, + c. the trigger "On PO state change" + d. snippet like + + if record.state == 'purchase': + record._edi_send_via_edi(conf.type_id) + + +3. Assign the configuration to a supplier +4. Create a PO for that supplier and confirm the order diff --git a/edi_purchase_ubl_output_oca/readme/CONTRIBUTORS.rst b/edi_purchase_ubl_output_oca/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..f1c71bce1 --- /dev/null +++ b/edi_purchase_ubl_output_oca/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Simone Orsi diff --git a/edi_purchase_ubl_output_oca/readme/DESCRIPTION.rst b/edi_purchase_ubl_output_oca/readme/DESCRIPTION.rst new file mode 100644 index 000000000..561e6b748 --- /dev/null +++ b/edi_purchase_ubl_output_oca/readme/DESCRIPTION.rst @@ -0,0 +1,6 @@ +Handle purchase exchanges with the EDI framework. + +This module is mostly a glue module for `purchase_order_ubl` with `edi_oca`. + +Allows you to generate and send purchase orders as UBL XML files +with a simple configuration. diff --git a/edi_purchase_ubl_output_oca/static/description/icon.png b/edi_purchase_ubl_output_oca/static/description/icon.png new file mode 100644 index 000000000..1dcc49c24 Binary files /dev/null and b/edi_purchase_ubl_output_oca/static/description/icon.png differ diff --git a/edi_purchase_ubl_output_oca/static/description/index.html b/edi_purchase_ubl_output_oca/static/description/index.html new file mode 100644 index 000000000..ea1b01031 --- /dev/null +++ b/edi_purchase_ubl_output_oca/static/description/index.html @@ -0,0 +1,439 @@ + + + + + +README.rst + + + +
    + + + +Odoo Community Association + +
    +

    EDI UBL Purchase

    + +

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

    +

    Handle purchase exchanges with the EDI framework.

    +

    This module is mostly a glue module for purchase_order_ubl with edi_oca.

    +

    Allows you to generate and send purchase orders as UBL XML files.

    +
    +

    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

    +
      +
    • Camptocamp
    • +
    +
    + +
    +

    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:

    +

    simahawk

    +

    This module is part of the OCA/edi project on GitHub.

    +

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

    +
    +
    +
    +
    + + diff --git a/edi_purchase_ubl_output_oca/tests/__init__.py b/edi_purchase_ubl_output_oca/tests/__init__.py new file mode 100644 index 000000000..427d09a22 --- /dev/null +++ b/edi_purchase_ubl_output_oca/tests/__init__.py @@ -0,0 +1 @@ +from . import test_generate diff --git a/edi_purchase_ubl_output_oca/tests/test_generate.py b/edi_purchase_ubl_output_oca/tests/test_generate.py new file mode 100644 index 000000000..9cddc6dc5 --- /dev/null +++ b/edi_purchase_ubl_output_oca/tests/test_generate.py @@ -0,0 +1,98 @@ +# Copyright 2026 Camptocamp SA +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.tests.common import TransactionCase + +from odoo.addons.purchase_order_ubl.tests.common import PurchaseOrderUblMixin + + +class TestPurchaseUBLOutputGenerate(PurchaseOrderUblMixin, TransactionCase): + """Ensure ``edi.output.ubl.purchase.order`` produces a valid UBL XML file.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env( + context=dict( + cls.env.context, tracking_disable=True, edi__skip_quick_exec=True + ) + ) + cls._setup_purchase_ubl_records() + cls.backend_type = cls.env.ref("edi_ubl_oca.edi_backend_type_ubl") + cls.backend = cls.env["edi.backend"].create( + { + "name": "UBL purchase test backend", + "backend_type_id": cls.backend_type.id, + } + ) + generator_model = cls.env["ir.model"]._get("edi.output.ubl.purchase.order") + cls.exc_type = cls.env["edi.exchange.type"].create( + { + "name": "Test UBL PO out", + "code": "test_ubl_po_out", + "direction": "output", + "exchange_file_ext": "xml", + "exchange_filename_pattern": "{record.id}-{type.code}-{dt}", + "backend_id": cls.backend.id, + "backend_type_id": cls.backend_type.id, + "generate_model_id": generator_model.id, + } + ) + + def _create_exchange_record(self): + return self.backend.create_record( + self.exc_type.code, + {"model": self.order._name, "res_id": self.order.id}, + ) + + def _generate_xml(self, version): + record = self._create_exchange_record() + record.with_context(ubl_version=version).action_exchange_generate() + self.assertTrue(record.exchange_file) + return record._get_file_content() + + def test_generate_order_confirmed(self): + self.order.button_confirm() + self.assertEqual(self.order.state, "purchase") + for version in ("2.1", "2.2"): + with self.subTest(version=version): + xml_string = self._generate_xml(version) + self._assert_valid_ubl_xml(xml_string, "Order", version) + + def test_generate_rfq(self): + self.assertIn(self.order.state, self.order.get_rfq_states()) + for version in ("2.1", "2.2"): + with self.subTest(version=version): + xml_string = self._generate_xml(version) + self._assert_valid_ubl_xml(xml_string, "RequestForQuotation", version) + + def test_generate_skip_taxes_via_advanced_settings(self): + """``advanced_settings_edit`` must propagate ``env_ctx`` to generate. + + Setting ``ubl_add_item__skip_taxes: true`` on the ``generate`` + component env_ctx must result in no ``ClassifiedTaxCategory`` + nodes in the produced UBL XML. + """ + self.exc_type.advanced_settings_edit = ( + "execution_model:\n" + " generate:\n" + " env_ctx:\n" + " ubl_add_item__skip_taxes: true\n" + ) + self.order.button_confirm() + xml_string = self._generate_xml("2.1") + parsed = self._assert_valid_ubl_xml(xml_string, "Order", "2.1") + tax_nodes = self._classified_tax_categories(parsed) + self.assertFalse( + tax_nodes, + "ClassifiedTaxCategory must be skipped when " + "ubl_add_item__skip_taxes is set via advanced_settings_edit", + ) + + def test_generate_taxes_included_by_default(self): + """Without any env_ctx, ClassifiedTaxCategory must be present.""" + self.order.button_confirm() + xml_string = self._generate_xml("2.1") + parsed = self._assert_valid_ubl_xml(xml_string, "Order", "2.1") + self.assertTrue(self._classified_tax_categories(parsed)) diff --git a/setup/_metapackage/pyproject.toml b/setup/_metapackage/pyproject.toml index 988bd3ff5..abcb4d0a4 100644 --- a/setup/_metapackage/pyproject.toml +++ b/setup/_metapackage/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "odoo-addons-oca-edi-framework" -version = "18.0.20260514.0" +version = "18.0.20260524.0" dependencies = [ "odoo-addon-edi_account_core_oca==18.0.*", "odoo-addon-edi_account_oca==18.0.*", @@ -13,6 +13,7 @@ dependencies = [ "odoo-addon-edi_oca==18.0.*", "odoo-addon-edi_party_data_oca==18.0.*", "odoo-addon-edi_product_oca==18.0.*", + "odoo-addon-edi_purchase_oca==18.0.*", "odoo-addon-edi_queue_oca==18.0.*", "odoo-addon-edi_record_metadata_oca==18.0.*", "odoo-addon-edi_sale_endpoint==18.0.*",