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_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/models/edi_configuration.py b/edi_core_oca/models/edi_configuration.py index 81780196b..7a77fe9d8 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._( @@ -200,6 +209,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 fda905ab5..ce150f044 100644 --- a/edi_core_oca/models/edi_exchange_record.py +++ b/edi_core_oca/models/edi_exchange_record.py @@ -529,8 +529,20 @@ 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 + """Trigger a component event linked to this backend and edi exchange.""" + 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/newsfragments/.gitkeep b/edi_core_oca/readme/newsfragments/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/edi_core_oca/readme/newsfragments/global-edi-conf-events.feature b/edi_core_oca/readme/newsfragments/global-edi-conf-events.feature new file mode 100644 index 000000000..d2678fe28 --- /dev/null +++ b/edi_core_oca/readme/newsfragments/global-edi-conf-events.feature @@ -0,0 +1,20 @@ +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. diff --git a/edi_core_oca/tests/test_edi_configuration.py b/edi_core_oca/tests/test_edi_configuration.py index 562f5b540..e6bfb21f1 100644 --- a/edi_core_oca/tests/test_edi_configuration.py +++ b/edi_core_oca/tests/test_edi_configuration.py @@ -158,3 +158,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_purchase_oca/README.rst b/edi_purchase_oca/README.rst new file mode 100644 index 000000000..bc6dac5a1 --- /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:8ab40ab13f4a26ea0ba04ff19e53af88e490d1b2e783699454ae6255422e00c3 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/19.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-19-0/edi-framework-19-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=19.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..0155df0e9 --- /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": "19.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..3cf50cd17 --- /dev/null +++ b/edi_purchase_oca/i18n/edi_purchase_oca.pot @@ -0,0 +1,109 @@ +# 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" +"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.fields,field_description:edi_purchase_oca.field_purchase_order__disable_edi_auto +msgid "Disable auto" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order__display_name +msgid "Display Name" +msgstr "" + +#. 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 "" + +#. 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 "" + +#. 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 "" + +#. 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 "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order__edi_config +msgid "Edi Config" +msgstr "" + +#. 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 "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order__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 +msgid "Exchange Record Count" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.ui.menu,name:edi_purchase_oca.menu_purchase_edi_root +msgid "Exchange records" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order__id +msgid "ID" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.model.fields,field_description:edi_purchase_oca.field_purchase_order____last_update +msgid "Last Modified on" +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 Record" +msgstr "" + +#. module: edi_purchase_oca +#: model:ir.ui.menu,name:edi_purchase_oca.menu_purchase_edi_exchange_record +msgid "Purchase Orders" +msgstr "" + +#. 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 "" + +#. 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 "" 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/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..276344d74 --- /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..3a921bacb --- /dev/null +++ b/edi_purchase_oca/tests/common.py @@ -0,0 +1,83 @@ +# 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, Domain + +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( + Domain([("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( + Domain([("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( + Domain([("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..c3ae45525 --- /dev/null +++ b/edi_purchase_oca/tests/test_generate.py @@ -0,0 +1,97 @@ +# 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.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + 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_record_metadata_oca/__manifest__.py b/edi_record_metadata_oca/__manifest__.py index 9ad020919..b7812f204 100644 --- a/edi_record_metadata_oca/__manifest__.py +++ b/edi_record_metadata_oca/__manifest__.py @@ -8,7 +8,7 @@ Allow to store metadata for related records. """, "version": "19.0.1.0.0", - "development_status": "Alpha", + "development_status": "Beta", "license": "LGPL-3", "website": "https://github.com/OCA/edi-framework", "author": "Camptocamp, Odoo Community Association (OCA)",