diff --git a/edi_storage_oca/__manifest__.py b/edi_storage_oca/__manifest__.py
index 39f47e247..1fac0f943 100644
--- a/edi_storage_oca/__manifest__.py
+++ b/edi_storage_oca/__manifest__.py
@@ -15,6 +15,7 @@
"depends": ["edi_core_oca", "fs_storage"],
"data": [
"data/cron.xml",
+ "data/edi_configuration.xml",
"security/ir_model_access.xml",
"views/edi_backend_views.xml",
],
diff --git a/edi_storage_oca/data/edi_configuration.xml b/edi_storage_oca/data/edi_configuration.xml
new file mode 100644
index 000000000..53956f2eb
--- /dev/null
+++ b/edi_storage_oca/data/edi_configuration.xml
@@ -0,0 +1,29 @@
+
+
+
+
+ Storage: move input file to done
+
+
+
+ record.backend_id._storage_on_edi_exchange_done(record)
+
+
+
+ Storage: move input file to error
+
+
+
+ record.backend_id._storage_on_edi_exchange_error(record)
+
+
diff --git a/edi_storage_oca/models/edi_backend.py b/edi_storage_oca/models/edi_backend.py
index e6767a789..4e3fe51ba 100644
--- a/edi_storage_oca/models/edi_backend.py
+++ b/edi_storage_oca/models/edi_backend.py
@@ -148,3 +148,53 @@ def _storage_new_exchange_record_vals(self, file_name):
"edi_exchange_state": "input_pending",
"storage_id": self.storage_id.id,
}
+
+ def _storage_on_edi_exchange_done(self, exchange_record):
+ """
+ Move an input file from the pending dir to the done dir.
+
+ Intended to be invoked from a global 'edi.configuration' snippet
+ bound to the 'on_edi_exchange_done' trigger.
+ """
+ self.ensure_one()
+ storage = exchange_record.storage_id
+ if exchange_record.direction != "input" or not storage:
+ return False
+ if not self.input_dir_done:
+ return False
+ file_name = exchange_record.exchange_filename
+ pending_dir = exchange_record.type_id._storage_fullpath(
+ self.input_dir_pending
+ ).as_posix()
+ done_dir = exchange_record.type_id._storage_fullpath(
+ self.input_dir_done
+ ).as_posix()
+ error_dir = exchange_record.type_id._storage_fullpath(
+ self.input_dir_error
+ ).as_posix()
+ res = storage._move_file(pending_dir, done_dir, file_name)
+ if not res and self.input_dir_error:
+ res = storage._move_file(error_dir, done_dir, file_name)
+ return res
+
+ def _storage_on_edi_exchange_error(self, exchange_record):
+ """
+ Move an input file from the pending dir to the error dir.
+
+ Intended to be invoked from a global 'edi.configuration' snippet
+ bound to the 'on_edi_exchange_error' trigger.
+ """
+ self.ensure_one()
+ storage = exchange_record.storage_id
+ if exchange_record.direction != "input" or not storage:
+ return False
+ if not self.input_dir_error:
+ return False
+ file_name = exchange_record.exchange_filename
+ pending_dir = exchange_record.type_id._storage_fullpath(
+ self.input_dir_pending
+ ).as_posix()
+ error_dir = exchange_record.type_id._storage_fullpath(
+ self.input_dir_error
+ ).as_posix()
+ return storage._move_file(pending_dir, error_dir, file_name)
diff --git a/edi_storage_oca/readme/CONFIGURE.md b/edi_storage_oca/readme/CONFIGURE.md
new file mode 100644
index 000000000..4050a7424
--- /dev/null
+++ b/edi_storage_oca/readme/CONFIGURE.md
@@ -0,0 +1,10 @@
+This module has two **inactive** global `edi.configuration` records
+that move the input file across the storage directories on
+`on_edi_exchange_done` / `on_edi_exchange_error`:
+
+- *Storage: move input file, pending → done (fallback error → done)
+- *Storage: move input file, pending → error
+
+Before enabling them you **must** set `backend_id` on the record:
+otherwise the global match runs against every backend in the database,
+including non-storage ones.
diff --git a/edi_storage_oca/tests/__init__.py b/edi_storage_oca/tests/__init__.py
index 0a73fb8a1..54510bc1a 100644
--- a/edi_storage_oca/tests/__init__.py
+++ b/edi_storage_oca/tests/__init__.py
@@ -1,2 +1,3 @@
from . import test_edi_backend_storage
+from . import test_event_listener
from . import test_exchange_type
diff --git a/edi_storage_oca/tests/test_event_listener.py b/edi_storage_oca/tests/test_event_listener.py
new file mode 100644
index 000000000..2b3fbc400
--- /dev/null
+++ b/edi_storage_oca/tests/test_event_listener.py
@@ -0,0 +1,88 @@
+# Copyright 2020 ACSONE
+# @author: Simone Orsi
+# Copyright 2026 ForgeFlow S.L. (https://www.forgeflow.com)
+# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
+
+import base64
+from unittest import mock
+
+from odoo_test_helper import FakeModelLoader
+
+from odoo.addons.edi_core_oca.tests.common import EDIBackendCommonTestCase
+from odoo.addons.edi_core_oca.tests.fake_models import EdiTestExecution
+
+STORAGE_MOVE_FILE_PATH = "odoo.addons.fs_storage.models.fs_storage.FSStorage._move_file"
+
+
+class TestStorageEventListener(EDIBackendCommonTestCase):
+ @classmethod
+ def _get_backend(cls):
+ return cls.env.ref("edi_storage_oca.demo_edi_backend_storage")
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.conf_done = cls.env.ref("edi_storage_oca.edi_conf_storage_move_on_done")
+ cls.conf_error = cls.env.ref("edi_storage_oca.edi_conf_storage_move_on_error")
+ (cls.conf_done | cls.conf_error).write(
+ {"active": True, "backend_id": cls.backend.id}
+ )
+
+ vals = {
+ "model": cls.partner._name,
+ "res_id": cls.partner.id,
+ "exchange_file": base64.b64encode(b"1234"),
+ "storage_id": cls.backend.storage_id.id,
+ }
+ cls.record = cls.backend.create_record("test_csv_input", vals)
+
+ def setUp(self):
+ super().setUp()
+ self.loader = FakeModelLoader(self.env, self.__module__)
+ self.loader.backup_registry()
+
+ self.loader.update_registry((EdiTestExecution,))
+ fake_model = self.env["ir.model"].search(
+ [("model", "=", "edi.framework.test.execution")]
+ )
+ self.exchange_type_in.process_model_id = fake_model
+ self.exchange_type_in.input_validate_model_id = fake_model
+
+ def tearDown(self):
+ self.loader.restore_registry()
+ super().tearDown()
+
+ def _patch_move_file(self):
+ return mock.patch(STORAGE_MOVE_FILE_PATH, autospec=True, return_value=True)
+
+ def _expected_dir(self, raw_dir):
+ return self.exchange_type_in._storage_fullpath(raw_dir).as_posix()
+
+ def test_01_process_record_success(self):
+ self.record.write({"edi_exchange_state": "input_received"})
+ with self._patch_move_file() as mocked:
+ self.record.action_exchange_process()
+ mocked.assert_called_once()
+ storage, from_dir_str, to_dir_str, filename = mocked.call_args[0]
+ self.assertEqual(storage, self.backend.storage_id)
+ self.assertEqual(
+ from_dir_str, self._expected_dir(self.backend.input_dir_pending)
+ )
+ self.assertEqual(to_dir_str, self._expected_dir(self.backend.input_dir_done))
+ self.assertEqual(filename, self.record.exchange_filename)
+
+ def test_02_process_record_with_error(self):
+ self.record.write({"edi_exchange_state": "input_received"})
+ self.record._set_file_content("TEST %d" % self.record.id)
+ with self._patch_move_file() as mocked:
+ self.record.with_context(
+ test_break_process="OOPS! Something went wrong :("
+ ).action_exchange_process()
+ mocked.assert_called_once()
+ storage, from_dir_str, to_dir_str, filename = mocked.call_args[0]
+ self.assertEqual(storage, self.backend.storage_id)
+ self.assertEqual(
+ from_dir_str, self._expected_dir(self.backend.input_dir_pending)
+ )
+ self.assertEqual(to_dir_str, self._expected_dir(self.backend.input_dir_error))
+ self.assertEqual(filename, self.record.exchange_filename)