diff --git a/edi_queue_oca/README.rst b/edi_queue_oca/README.rst new file mode 100644 index 000000000..104a1ff02 --- /dev/null +++ b/edi_queue_oca/README.rst @@ -0,0 +1,167 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +============= +Edi Queue Oca +============= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:1ab25d69fc78c38970945f7e4e2f4c5426629c0cd155b4e06ac962e24645418f + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/20.0/edi_queue_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-20-0/edi-framework-20-0-edi_queue_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=20.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module integrates EDI exchange records with `Queue +Job `__, so that the four core exchange +actions — **generate**, **send**, **receive**, and **process** — are +dispatched as background jobs instead of running synchronously. + +Each exchange type can optionally route its jobs to a specific channel, +set a priority, or **hold all jobs until a fixed time of day** — useful +when a trading partner's receiving system has a nightly processing +window or when the operator wants to concentrate resource-intensive EDI +work in off-peak hours. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Automatic job dispatch +---------------------- + +All exchange actions on ``edi.exchange.record`` are dispatched as +background jobs automatically. No per-record configuration is required; +the integration is active for every exchange type as soon as the module +is installed. + +Per-type job configuration +-------------------------- + +Each **Exchange Type** gains a *Queue* tab with optional settings: + ++---------------------------+------------------------------------------+ +| Field | Purpose | ++===========================+==========================================+ +| **Job channel** | Route jobs to a specific channel (e.g. | +| | ``root.edi.high``). | ++---------------------------+------------------------------------------+ +| **Job priority** | Integer priority passed to the queue job | +| | (lower = higher priority). | ++---------------------------+------------------------------------------+ +| **Enable ETA Scheduling** | Toggle to activate daily job | +| | accumulation (see below). | ++---------------------------+------------------------------------------+ +| **Execution time** | Hour, minute, and timezone at which | +| | accumulated jobs are released. Visible | +| | only when ETA Scheduling is enabled. | ++---------------------------+------------------------------------------+ + +Accumulating jobs until a fixed daily time +------------------------------------------ + +Enabling **ETA Scheduling** on an exchange type causes every job created +for that type to be **held in the queue** until the configured time of +day, rather than being processed immediately. All jobs that arrive +during the day accumulate and are released together at that moment. + +**Typical use cases:** + +- A trading partner's receiving system only processes incoming files at + a specific nightly window (e.g. 22:00). +- Resource-intensive EDI operations (large exports, heavy + transformations) should be deferred to off-peak hours to avoid + competing with daytime workloads. +- Operational preference to send a batch of documents at a predictable + daily time instead of dispatching them one by one in real time. + +The execution time is configured with three fields: + +- **Hour** — hour of the day (00–23). +- **Minute** — minute of the hour (00–59). +- **Timezone** — the timezone in which the hour and minute are + interpreted. Defaults to the current user's timezone. + +At runtime the configured time is converted to the next matching UTC +datetime and set as the queue job ETA. If the target time for today has +already passed, the job is automatically scheduled for the same time +tomorrow. + +When **Enable ETA Scheduling** is off, jobs are dispatched immediately +as usual. + +Duplicate-job prevention +------------------------ + +An identity key is attached to every queued job, so re-triggering an +action for a record that already has a pending job does not enqueue a +duplicate. + +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 +------- + +* Dixmit +* Camptocamp + +Contributors +------------ + +- Simone Orsi +- Enric Tobella +- Manuel Regidor +- Thien Vo +- Jordi Masvidal + +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_queue_oca/__init__.py b/edi_queue_oca/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/edi_queue_oca/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/edi_queue_oca/__manifest__.py b/edi_queue_oca/__manifest__.py new file mode 100644 index 000000000..22dded226 --- /dev/null +++ b/edi_queue_oca/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2025 Dixmit +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +{ + "name": "Edi Queue Oca", + "summary": """Set Queue Jobs on EDI""", + "version": "20.0.1.0.0", + "license": "LGPL-3", + "author": "Dixmit,Camptocamp,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/edi-framework", + "depends": ["edi_core_oca", "queue_job"], + "data": [ + "views/edi_exchange_type.xml", + "security/ir.access.csv", + "data/job_channel.xml", + "data/job_function.xml", + "views/edi_exchange_record.xml", + ], + "demo": [], +} diff --git a/edi_queue_oca/data/job_channel.xml b/edi_queue_oca/data/job_channel.xml new file mode 100644 index 000000000..2ad0fc5a2 --- /dev/null +++ b/edi_queue_oca/data/job_channel.xml @@ -0,0 +1,11 @@ + + + + edi + + + + edi_exchange + + + diff --git a/edi_queue_oca/data/job_function.xml b/edi_queue_oca/data/job_function.xml new file mode 100644 index 000000000..2920861c1 --- /dev/null +++ b/edi_queue_oca/data/job_function.xml @@ -0,0 +1,53 @@ + + + + + action_exchange_generate + _job_on_fail_generate + + + + + action_exchange_send + _job_on_fail_send + + + + + action_exchange_receive + _job_on_fail_receive + + + + + action_exchange_process + _job_on_fail_process + + + + + exchange_create_ack_record + + + + + + exchange_generate + + + + + exchange_send + + + + + exchange_receive + + + + + exchange_process + + + diff --git a/edi_queue_oca/i18n/edi_queue_oca.pot b/edi_queue_oca/i18n/edi_queue_oca.pot new file mode 100644 index 000000000..2247e21e6 --- /dev/null +++ b/edi_queue_oca/i18n/edi_queue_oca.pot @@ -0,0 +1,112 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * edi_queue_oca +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.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_queue_oca +#: model:ir.model.fields,help:edi_queue_oca.field_edi_exchange_type__job_eta_enabled +msgid "" +"Accumulate all queue jobs for this exchange type and release them at the " +"daily time configured below, instead of dispatching each job immediately. " +"Use this when a trading partner only processes files at a fixed daily " +"window, or to concentrate resource-intensive EDI work in off-peak hours." +msgstr "" + +#. module: edi_queue_oca +#: model:ir.model.fields,field_description:edi_queue_oca.field_edi_backend__display_name +#: model:ir.model.fields,field_description:edi_queue_oca.field_edi_exchange_record__display_name +#: model:ir.model.fields,field_description:edi_queue_oca.field_edi_exchange_type__display_name +msgid "Display Name" +msgstr "" + +#. module: edi_queue_oca +#: model:ir.model,name:edi_queue_oca.model_edi_backend +msgid "EDI Backend" +msgstr "" + +#. module: edi_queue_oca +#: model:ir.model,name:edi_queue_oca.model_edi_exchange_type +msgid "EDI Exchange Type" +msgstr "" + +#. module: edi_queue_oca +#: model:ir.model,name:edi_queue_oca.model_edi_exchange_record +msgid "EDI exchange Record" +msgstr "" + +#. module: edi_queue_oca +#: model:ir.model.fields,help:edi_queue_oca.field_edi_exchange_type__job_eta_tz +msgid "ETA's timezone for jobs of this type" +msgstr "" + +#. module: edi_queue_oca +#: model:ir.model.fields,field_description:edi_queue_oca.field_edi_exchange_type__job_eta_enabled +msgid "Enable ETA Scheduling" +msgstr "" + +#. module: edi_queue_oca +#: model:ir.model.fields,field_description:edi_queue_oca.field_edi_exchange_type__job_eta_hour +msgid "Execution time - hour" +msgstr "" + +#. module: edi_queue_oca +#: model:ir.model.fields,field_description:edi_queue_oca.field_edi_exchange_type__job_eta_minute +msgid "Execution time - minute" +msgstr "" + +#. module: edi_queue_oca +#: model:ir.model.fields,field_description:edi_queue_oca.field_edi_exchange_type__job_eta_tz +msgid "Execution time - timezone" +msgstr "" + +#. module: edi_queue_oca +#: model:ir.model.fields,help:edi_queue_oca.field_edi_exchange_type__job_eta_hour +msgid "Hour of the day at which jobs for this type should be scheduled." +msgstr "" + +#. module: edi_queue_oca +#: model:ir.model.fields,field_description:edi_queue_oca.field_edi_backend__id +#: model:ir.model.fields,field_description:edi_queue_oca.field_edi_exchange_record__id +#: model:ir.model.fields,field_description:edi_queue_oca.field_edi_exchange_type__id +msgid "ID" +msgstr "" + +#. module: edi_queue_oca +#: model:ir.model.fields,field_description:edi_queue_oca.field_edi_exchange_type__job_channel_id +msgid "Job Channel" +msgstr "" + +#. module: edi_queue_oca +#: model:ir.model.fields,field_description:edi_queue_oca.field_edi_exchange_type__job_priority +msgid "Job Priority" +msgstr "" + +#. module: edi_queue_oca +#: model_terms:ir.ui.view,arch_db:edi_queue_oca.edi_exchange_record_form_view +msgid "Jobs" +msgstr "" + +#. module: edi_queue_oca +#: model:ir.model.fields,help:edi_queue_oca.field_edi_exchange_type__job_eta_minute +msgid "Minute of the hour at which jobs for this type should be scheduled." +msgstr "" + +#. module: edi_queue_oca +#: model_terms:ir.ui.view,arch_db:edi_queue_oca.edi_exchange_type_form_view +msgid "Queue" +msgstr "" + +#. module: edi_queue_oca +#: model:ir.model.fields,field_description:edi_queue_oca.field_edi_exchange_record__related_queue_jobs_count +msgid "Related Queue Jobs Count" +msgstr "" diff --git a/edi_queue_oca/i18n/it.po b/edi_queue_oca/i18n/it.po new file mode 100644 index 000000000..cc93cf0e4 --- /dev/null +++ b/edi_queue_oca/i18n/it.po @@ -0,0 +1,121 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * edi_queue_oca +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2026-07-07 10:46+0000\n" +"Last-Translator: mymage \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" +"X-Generator: Weblate 5.15.2\n" + +#. module: edi_queue_oca +#: model:ir.model.fields,help:edi_queue_oca.field_edi_exchange_type__job_eta_enabled +msgid "" +"Accumulate all queue jobs for this exchange type and release them at the " +"daily time configured below, instead of dispatching each job immediately. " +"Use this when a trading partner only processes files at a fixed daily " +"window, or to concentrate resource-intensive EDI work in off-peak hours." +msgstr "" +"Accumula tutti i processi in coda per questo tipo di scambio e li rilascia " +"all'orario giornaliero configurato di seguito, anziché inviare ciascun " +"processo immediatamente. Utilizza questa opzione quando un partner " +"commerciale elabora i file solo in una finestra temporale giornaliera fissa " +"o per concentrare le attività EDI che richiedono molte risorse nelle ore di " +"minore affluenza." + +#. module: edi_queue_oca +#: model:ir.model.fields,field_description:edi_queue_oca.field_edi_backend__display_name +#: model:ir.model.fields,field_description:edi_queue_oca.field_edi_exchange_record__display_name +#: model:ir.model.fields,field_description:edi_queue_oca.field_edi_exchange_type__display_name +msgid "Display Name" +msgstr "Nome visualizzato" + +#. module: edi_queue_oca +#: model:ir.model,name:edi_queue_oca.model_edi_backend +msgid "EDI Backend" +msgstr "Backend EDI" + +#. module: edi_queue_oca +#: model:ir.model,name:edi_queue_oca.model_edi_exchange_type +msgid "EDI Exchange Type" +msgstr "Tipo scambio EDI" + +#. module: edi_queue_oca +#: model:ir.model,name:edi_queue_oca.model_edi_exchange_record +msgid "EDI exchange Record" +msgstr "Record di scambio EDI" + +#. module: edi_queue_oca +#: model:ir.model.fields,help:edi_queue_oca.field_edi_exchange_type__job_eta_tz +msgid "ETA's timezone for jobs of this type" +msgstr "Fuso orario di ETA per lavori di questo tipo" + +#. module: edi_queue_oca +#: model:ir.model.fields,field_description:edi_queue_oca.field_edi_exchange_type__job_eta_enabled +msgid "Enable ETA Scheduling" +msgstr "Abilita schedulazione ETA" + +#. module: edi_queue_oca +#: model:ir.model.fields,field_description:edi_queue_oca.field_edi_exchange_type__job_eta_hour +msgid "Execution time - hour" +msgstr "Orario di esecuzione - ora" + +#. module: edi_queue_oca +#: model:ir.model.fields,field_description:edi_queue_oca.field_edi_exchange_type__job_eta_minute +msgid "Execution time - minute" +msgstr "Orario di esecuzione - minuto" + +#. module: edi_queue_oca +#: model:ir.model.fields,field_description:edi_queue_oca.field_edi_exchange_type__job_eta_tz +msgid "Execution time - timezone" +msgstr "Orario esecuzione - fuso orario" + +#. module: edi_queue_oca +#: model:ir.model.fields,help:edi_queue_oca.field_edi_exchange_type__job_eta_hour +msgid "Hour of the day at which jobs for this type should be scheduled." +msgstr "Ora del giorno alla quale schedulare i lavori di questo tipo." + +#. module: edi_queue_oca +#: model:ir.model.fields,field_description:edi_queue_oca.field_edi_backend__id +#: model:ir.model.fields,field_description:edi_queue_oca.field_edi_exchange_record__id +#: model:ir.model.fields,field_description:edi_queue_oca.field_edi_exchange_type__id +msgid "ID" +msgstr "ID" + +#. module: edi_queue_oca +#: model:ir.model.fields,field_description:edi_queue_oca.field_edi_exchange_type__job_channel_id +msgid "Job Channel" +msgstr "Canale lavoro" + +#. module: edi_queue_oca +#: model:ir.model.fields,field_description:edi_queue_oca.field_edi_exchange_type__job_priority +msgid "Job Priority" +msgstr "Priorità lavoro" + +#. module: edi_queue_oca +#: model_terms:ir.ui.view,arch_db:edi_queue_oca.edi_exchange_record_form_view +msgid "Jobs" +msgstr "Lavori" + +#. module: edi_queue_oca +#: model:ir.model.fields,help:edi_queue_oca.field_edi_exchange_type__job_eta_minute +msgid "Minute of the hour at which jobs for this type should be scheduled." +msgstr "Minuto dell'ora al quale schedulare i lavori di questo tipo." + +#. module: edi_queue_oca +#: model_terms:ir.ui.view,arch_db:edi_queue_oca.edi_exchange_type_form_view +msgid "Queue" +msgstr "Coda" + +#. module: edi_queue_oca +#: model:ir.model.fields,field_description:edi_queue_oca.field_edi_exchange_record__related_queue_jobs_count +msgid "Related Queue Jobs Count" +msgstr "Conteggio lavori in coda relativo" diff --git a/edi_queue_oca/models/__init__.py b/edi_queue_oca/models/__init__.py new file mode 100644 index 000000000..e1a8e889b --- /dev/null +++ b/edi_queue_oca/models/__init__.py @@ -0,0 +1,3 @@ +from . import edi_exchange_record +from . import edi_exchange_type +from . import edi_backend diff --git a/edi_queue_oca/models/edi_backend.py b/edi_queue_oca/models/edi_backend.py new file mode 100644 index 000000000..b06cda1b7 --- /dev/null +++ b/edi_queue_oca/models/edi_backend.py @@ -0,0 +1,22 @@ +# Copyright 2020 ACSONE SA +# Copyright 2021 Camptocamp +# Copyright 2025 Dixmit +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + + +from odoo import models + +from odoo.addons.queue_job.exception import RetryableJobError + + +class EDIBackend(models.Model): + _inherit = "edi.backend" + + def _send_retryable_exceptions(self): + # IOError is a base class for all connection errors + # OSError is a base class for all errors + # when dealing w/ internal or external systems or filesystems + return (IOError, OSError) + + def _retryable_exception(self): + return RetryableJobError diff --git a/edi_queue_oca/models/edi_exchange_record.py b/edi_queue_oca/models/edi_exchange_record.py new file mode 100644 index 000000000..ebf2009cf --- /dev/null +++ b/edi_queue_oca/models/edi_exchange_record.py @@ -0,0 +1,138 @@ +# Copyright 2021 Camptocamp SA +# Copyright 2025 Dixmit +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +import functools +from ast import literal_eval + +from odoo import fields, models + +from ..utils import exchange_record_job_identity_exact + + +class EdiExchangeRecord(models.Model): + _inherit = "edi.exchange.record" + + related_queue_jobs_count = fields.Integer( + compute="_compute_related_queue_jobs_count" + ) + + def _register_hook(self): + for function in [ + "action_exchange_send", + "action_exchange_receive", + "action_exchange_process", + "action_exchange_generate", + ]: + patched = self._patch_job_auto_delay(function) + setattr( + type(self), + function, + functools.update_wrapper( + patched, + getattr(type(self), function), + ), + ) + return super()._register_hook() + + def action_exchange_send_job_options(self): + return {"priority": 0} + + def _job_delay_params(self): + params = {} + exchange_type = self.type_id.sudo() + channel = exchange_type.job_channel_id + if channel: + params["channel"] = channel.complete_name + priority = exchange_type.job_priority + if priority: + params["priority"] = priority + if exchange_type.job_eta_enabled and (eta := exchange_type._get_job_eta()): + params["eta"] = eta + # Avoid generating the same job for the same record if existing + params["identity_key"] = exchange_record_job_identity_exact + return params + + def with_delay(self, **kw): + params = self._job_delay_params() + params.update(kw) + return super().with_delay(**params) + + def delayable(self, **kw): + params = self._job_delay_params() + params.update(kw) + return super().delayable(**params) + + def _job_retry_params(self): + return {} + + def _compute_related_queue_jobs_count(self): + for rec in self: + # TODO: We should refactor the object field on queue_job to use jsonb field + # so that we can search directly into it. + rec.related_queue_jobs_count = rec.env["queue.job"].search_count( + [("func_string", "like", str(rec))] + ) + + def action_view_related_queue_jobs(self): + self.ensure_one() + xmlid = "queue_job.action_queue_job" + action = self.env["ir.actions.act_window"]._for_xml_id(xmlid) + # Searching based on task name: + # Ex: `edi.exchange.record(1,).action_exchange_send()` + # TODO: We should refactor the object field on queue_job to use jsonb field + # so that we can search directly into it. + action["domain"] = [("func_string", "like", str(self))] + # Purge default search filters from ctx to avoid hiding records + ctx = action.get("context", {}) + if isinstance(ctx, str): + ctx = literal_eval(ctx) + # Update the current contexts + ctx.update(self.env.context) + action["context"] = { + k: v for k, v in ctx.items() if not k.startswith("search_default_") + } + # Drop ID otherwise the context will be loaded from the action's record + action.pop("id") + return action + + def action_exchange_generate_send_chained(self): + job1 = self.delayable().action_exchange_generate() + # Chain send job. + # Raise prio to max to send the record out as fast as possible. + job1.on_done(self.delayable(priority=0).action_exchange_send()) + job1.delay() + + def action_exchange_receive_process_chained(self): + job1 = self.delayable().action_exchange_receive() + # Chain process job. + # Raise prio to max to process the record as soon as it lands. + job1.on_done(self.delayable(priority=0).action_exchange_process()) + job1.delay() + + def _job_on_fail_generate(self, **kw): + return self._job_on_fail_update("validate_error", **kw) + + def _job_on_fail_send(self, **kw): + return self._job_on_fail_update("output_error_on_send", **kw) + + def _job_on_fail_receive(self, **kw): + return self._job_on_fail_update("input_receive_error", **kw) + + def _job_on_fail_process(self, **kw): + return self._job_on_fail_update("input_processed_error", **kw) + + def _job_on_fail_update(self, failed_state, **kw): + self.ensure_one() + # ``exc_message`` is the first argument of the exception: it is not + # always a string (e.g. the errno of an ``OSError``) and may be missing. + error = ": ".join( + str(kw[k]) for k in ("exc_name", "exc_message") if kw.get(k) is not None + ) + self.write( + { + "edi_exchange_state": failed_state, + "exchange_error": error, + "exchange_error_traceback": kw.get("exc_info"), + } + ) diff --git a/edi_queue_oca/models/edi_exchange_type.py b/edi_queue_oca/models/edi_exchange_type.py new file mode 100644 index 000000000..cb4d27557 --- /dev/null +++ b/edi_queue_oca/models/edi_exchange_type.py @@ -0,0 +1,77 @@ +# Copyright 2021 Camptocamp SA +# Copyright 2025 Dixmit +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +from datetime import UTC, datetime +from zoneinfo import ZoneInfo + +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models + +from odoo.addons.base.models.res_partner import _tz_get as timezone_selection + + +class EdiExchangeType(models.Model): + _inherit = "edi.exchange.type" + + job_channel_id = fields.Many2one( + comodel_name="queue.job.channel", + ) + job_priority = fields.Integer() + job_eta_enabled = fields.Boolean( + string="Enable ETA Scheduling", + help="Accumulate all queue jobs for this exchange type and release them " + "at the daily time configured below, instead of dispatching each job " + "immediately. Use this when a trading partner only processes files at a " + "fixed daily window, or to concentrate resource-intensive EDI work in " + "off-peak hours.", + ) + job_eta_hour = fields.Selection( + [(str(x).zfill(2), str(x).zfill(2)) for x in range(24)], + string="Execution time - hour", + help="Hour of the day at which jobs for this type should be scheduled.", + default="00", + ) + job_eta_minute = fields.Selection( + [(str(x).zfill(2), str(x).zfill(2)) for x in range(60)], + string="Execution time - minute", + help="Minute of the hour at which jobs for this type should be scheduled.", + default="00", + ) + job_eta_tz = fields.Selection( + timezone_selection, + string="Execution time - timezone", + help="ETA's timezone for jobs of this type", + default=lambda self: self._get_default_eta_tz(), + ) + + @api.model + def _get_default_eta_tz(self): + tz_list = timezone_selection(self) + return self.env.user.tz or (tz_list and tz_list[0][0]) or "UTC" + + def _get_job_eta(self): + """Returns the ETA for the current type as timezone-naive datetime""" + self.ensure_one() + if ( + not self.job_eta_enabled + or not self.job_eta_tz + or not self.job_eta_hour + or not self.job_eta_minute + ): + return None + + self.ensure_one() + now = datetime.now(UTC) + eta = datetime( + now.year, + now.month, + now.day, + int(self.job_eta_hour), + int(self.job_eta_minute), + tzinfo=ZoneInfo(self.job_eta_tz), + ).astimezone(UTC) + if eta < now: + eta += relativedelta(days=1) + return eta.replace(tzinfo=None) diff --git a/edi_queue_oca/pyproject.toml b/edi_queue_oca/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/edi_queue_oca/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/edi_queue_oca/readme/CONTRIBUTORS.md b/edi_queue_oca/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..405f579f4 --- /dev/null +++ b/edi_queue_oca/readme/CONTRIBUTORS.md @@ -0,0 +1,5 @@ +- Simone Orsi \<\> +- Enric Tobella \<\> +- Manuel Regidor \<\> +- Thien Vo \<\> +- Jordi Masvidal \<\> diff --git a/edi_queue_oca/readme/DESCRIPTION.md b/edi_queue_oca/readme/DESCRIPTION.md new file mode 100644 index 000000000..60ee6efb6 --- /dev/null +++ b/edi_queue_oca/readme/DESCRIPTION.md @@ -0,0 +1,9 @@ +This module integrates EDI exchange records with +[Queue Job](https://github.com/OCA/queue), so that the four core exchange +actions — **generate**, **send**, **receive**, and **process** — are dispatched +as background jobs instead of running synchronously. + +Each exchange type can optionally route its jobs to a specific channel, set a +priority, or **hold all jobs until a fixed time of day** — useful when a +trading partner's receiving system has a nightly processing window or when the +operator wants to concentrate resource-intensive EDI work in off-peak hours. diff --git a/edi_queue_oca/readme/USAGE.md b/edi_queue_oca/readme/USAGE.md new file mode 100644 index 000000000..0303a275d --- /dev/null +++ b/edi_queue_oca/readme/USAGE.md @@ -0,0 +1,52 @@ +## Automatic job dispatch + +All exchange actions on `edi.exchange.record` are dispatched as background +jobs automatically. No per-record configuration is required; the integration +is active for every exchange type as soon as the module is installed. + +## Per-type job configuration + +Each **Exchange Type** gains a *Queue* tab with optional settings: + +| Field | Purpose | +|---|---| +| **Job channel** | Route jobs to a specific channel (e.g. `root.edi.high`). | +| **Job priority** | Integer priority passed to the queue job (lower = higher priority). | +| **Enable ETA Scheduling** | Toggle to activate daily job accumulation (see below). | +| **Execution time** | Hour, minute, and timezone at which accumulated jobs are released. Visible only when ETA Scheduling is enabled. | + +## Accumulating jobs until a fixed daily time + +Enabling **ETA Scheduling** on an exchange type causes every job created for +that type to be **held in the queue** until the configured time of day, rather +than being processed immediately. All jobs that arrive during the day +accumulate and are released together at that moment. + +**Typical use cases:** + +- A trading partner's receiving system only processes incoming files at a + specific nightly window (e.g. 22:00). +- Resource-intensive EDI operations (large exports, heavy transformations) + should be deferred to off-peak hours to avoid competing with daytime + workloads. +- Operational preference to send a batch of documents at a predictable daily + time instead of dispatching them one by one in real time. + +The execution time is configured with three fields: + +- **Hour** — hour of the day (00–23). +- **Minute** — minute of the hour (00–59). +- **Timezone** — the timezone in which the hour and minute are interpreted. + Defaults to the current user's timezone. + +At runtime the configured time is converted to the next matching UTC datetime +and set as the queue job ETA. If the target time for today has already passed, +the job is automatically scheduled for the same time tomorrow. + +When **Enable ETA Scheduling** is off, jobs are dispatched immediately as +usual. + +## Duplicate-job prevention + +An identity key is attached to every queued job, so re-triggering an action +for a record that already has a pending job does not enqueue a duplicate. diff --git a/edi_queue_oca/security/ir.access.csv b/edi_queue_oca/security/ir.access.csv new file mode 100644 index 000000000..1d70b7183 --- /dev/null +++ b/edi_queue_oca/security/ir.access.csv @@ -0,0 +1,2 @@ +id,name,model_id,group_id/id,operation,domain +access_queue_job_user,access_queue_job edi user,queue.job,base_edi.group_edi_user,r, diff --git a/edi_queue_oca/static/description/icon.png b/edi_queue_oca/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/edi_queue_oca/static/description/icon.png differ diff --git a/edi_queue_oca/static/description/index.html b/edi_queue_oca/static/description/index.html new file mode 100644 index 000000000..0b02901a1 --- /dev/null +++ b/edi_queue_oca/static/description/index.html @@ -0,0 +1,529 @@ + + + + + +Edi Queue Oca + + + +
+ + + +Odoo Community Association + +
+

Edi Queue Oca

+ +

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

+

This module integrates EDI exchange records with Queue +Job, so that the four core exchange +actions — generate, send, receive, and process — are +dispatched as background jobs instead of running synchronously.

+

Each exchange type can optionally route its jobs to a specific channel, +set a priority, or hold all jobs until a fixed time of day — useful +when a trading partner’s receiving system has a nightly processing +window or when the operator wants to concentrate resource-intensive EDI +work in off-peak hours.

+

Table of contents

+ +
+

Usage

+
+

Automatic job dispatch

+

All exchange actions on edi.exchange.record are dispatched as +background jobs automatically. No per-record configuration is required; +the integration is active for every exchange type as soon as the module +is installed.

+
+
+

Per-type job configuration

+

Each Exchange Type gains a Queue tab with optional settings:

+ ++++ + + + + + + + + + + + + + + + + + + + +
FieldPurpose
Job channelRoute jobs to a specific channel (e.g. +root.edi.high).
Job priorityInteger priority passed to the queue job +(lower = higher priority).
Enable ETA SchedulingToggle to activate daily job +accumulation (see below).
Execution timeHour, minute, and timezone at which +accumulated jobs are released. Visible +only when ETA Scheduling is enabled.
+
+
+

Accumulating jobs until a fixed daily time

+

Enabling ETA Scheduling on an exchange type causes every job created +for that type to be held in the queue until the configured time of +day, rather than being processed immediately. All jobs that arrive +during the day accumulate and are released together at that moment.

+

Typical use cases:

+
    +
  • A trading partner’s receiving system only processes incoming files at +a specific nightly window (e.g. 22:00).
  • +
  • Resource-intensive EDI operations (large exports, heavy +transformations) should be deferred to off-peak hours to avoid +competing with daytime workloads.
  • +
  • Operational preference to send a batch of documents at a predictable +daily time instead of dispatching them one by one in real time.
  • +
+

The execution time is configured with three fields:

+
    +
  • Hour — hour of the day (00–23).
  • +
  • Minute — minute of the hour (00–59).
  • +
  • Timezone — the timezone in which the hour and minute are +interpreted. Defaults to the current user’s timezone.
  • +
+

At runtime the configured time is converted to the next matching UTC +datetime and set as the queue job ETA. If the target time for today has +already passed, the job is automatically scheduled for the same time +tomorrow.

+

When Enable ETA Scheduling is off, jobs are dispatched immediately +as usual.

+
+
+

Duplicate-job prevention

+

An identity key is attached to every queued job, so re-triggering an +action for a record that already has a pending job does not enqueue a +duplicate.

+
+
+
+

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

+
    +
  • Dixmit
  • +
  • Camptocamp
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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

+

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_queue_oca/tests/__init__.py b/edi_queue_oca/tests/__init__.py new file mode 100644 index 000000000..0beb51b79 --- /dev/null +++ b/edi_queue_oca/tests/__init__.py @@ -0,0 +1,16 @@ +from . import test_exchange_type +from . import test_record +from . import test_backend_jobs +from . import test_backend_input_jobs +from . import test_backend_output_jobs + + +# We want to execute the same tests from the original modules to ensure +# that everything works as expected. +from odoo.addons.edi_core_oca.tests import ( + test_backend_input, + test_backend_output, + test_backend_process, + test_edi_backend_cron, + test_quick_exec, +) diff --git a/edi_queue_oca/tests/common.py b/edi_queue_oca/tests/common.py new file mode 100644 index 000000000..218532566 --- /dev/null +++ b/edi_queue_oca/tests/common.py @@ -0,0 +1,19 @@ +# Copyright 2026 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo.addons.edi_core_oca.tests.common import EDIBackendCommonTestCase + + +class EDIQueueCommonTestCase(EDIBackendCommonTestCase): + def _make_record(self): + return self.backend.create_record( + "test_csv_input", + {"model": self.partner._name, "res_id": self.partner.id}, + ) + + def _get_delayed(self, record): + delayed = record.with_context(queue_job__no_delay=False).with_delay() + # Suppress "prepared but never delayed" warning + # `Delayable Delayable(edi.exchange.record*) was prepared but never delayed` + delayed.delayable._generated_job = object() + return delayed diff --git a/edi_queue_oca/tests/test_backend_input_jobs.py b/edi_queue_oca/tests/test_backend_input_jobs.py new file mode 100644 index 000000000..7118511d7 --- /dev/null +++ b/edi_queue_oca/tests/test_backend_input_jobs.py @@ -0,0 +1,55 @@ +# Copyright 2026 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo.tests import tagged + +from odoo.addons.queue_job.tests.common import trap_jobs + +from .common import EDIQueueCommonTestCase + + +@tagged("-at_install", "post_install") +class EDIBackendTestInputJobsCase(EDIQueueCommonTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.record = cls.backend.create_record( + "test_csv_input", + { + "model": cls.partner._name, + "res_id": cls.partner.id, + "edi_exchange_state": "input_pending", + }, + ) + + @classmethod + def _setup_context(cls): + # Re-enable jobs + return dict(super()._setup_context(), queue_job__no_delay=False) + + def test_receive_process_chained(self): + """Receiving and processing a record queues one chain of two jobs. + + Scenario: + 1. Ask a pending input record to be received and processed. + 2. Run the queued jobs. + Expected: + - A receive job and a process job are queued. + - The process job waits for the receive job and gets the top priority. + - Once both ran, the record has been processed. + """ + with trap_jobs() as trap: + self.record.action_exchange_receive_process_chained() + trap.assert_jobs_count(2) + trap.assert_enqueued_job(self.record.action_exchange_receive) + trap.assert_enqueued_job( + self.record.action_exchange_process, properties={"priority": 0} + ) + process_job, receive_job = sorted( + trap.enqueued_jobs, key=lambda job: job.method_name + ) + self.assertEqual(process_job.depends_on, {receive_job}) + trap.perform_enqueued_jobs() + self.assertRecordValues( + self.record, [{"edi_exchange_state": "input_processed"}] + ) diff --git a/edi_queue_oca/tests/test_backend_jobs.py b/edi_queue_oca/tests/test_backend_jobs.py new file mode 100644 index 000000000..0caa07690 --- /dev/null +++ b/edi_queue_oca/tests/test_backend_jobs.py @@ -0,0 +1,234 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from unittest import mock + +from requests.exceptions import ConnectionError as ReqConnectionError + +from odoo.orm.model_classes import add_to_registry +from odoo.tests import tagged + +from odoo.addons.edi_core_oca.tests.common import EDIBackendCommonTestCase +from odoo.addons.edi_core_oca.utils import EDIExchangeActionResult +from odoo.addons.queue_job.exception import RetryableJobError +from odoo.addons.queue_job.tests.common import JobMixin + + +@tagged("-at_install", "post_install") +class EDIBackendTestJobsCase(EDIBackendCommonTestCase, JobMixin): + @classmethod + def _setup_context(cls): + return dict(super()._setup_context(), queue_job__no_delay=None) + + @classmethod + def _setup_env(cls): + super()._setup_env() + # Load fake models ->/ + from odoo.addons.edi_core_oca.tests.fake_models import EdiTestExecution + + add_to_registry(cls.registry, EdiTestExecution) + cls.registry._setup_models__(cls.env.cr, ["edi.framework.test.execution"]) + cls.registry.init_models( + cls.env.cr, + ["edi.framework.test.execution"], + {"models_to_check": True}, + ) + cls.addClassCleanup(cls.registry.__delitem__, "edi.framework.test.execution") + return super()._setup_env() + + def setUp(self): + super().setUp() + self.model = self.env["ir.model"].search( + [("model", "=", "edi.framework.test.execution")] + ) + self.exchange_type_out.generate_model_id = self.model + self.exchange_type_out.send_model_id = self.model + self.exchange_type_out.output_validate_model_id = self.model + self.exchange_type_in.receive_model_id = self.model + self.exchange_type_in.process_model_id = self.model + self.exchange_type_in.input_validate_model_id = self.model + + def _get_related_jobs(self, record): + # Use domain in action to find all related jobs + record.ensure_one() + action = record.action_view_related_queue_jobs() + return self.env["queue.job"].search(action["domain"]) + + def _test_output_return(self, message, expected_message): + job_counter = self.job_counter() + vals = { + "model": self.partner._name, + "res_id": self.partner.id, + } + record = self.backend.create_record("test_csv_output", vals) + self.assertEqual(record.edi_exchange_state, "new") + job = self.backend.with_delay().exchange_generate(record) + created = job_counter.search_created() + self.assertEqual(len(created), 1) + self.assertEqual( + created.name, "Generate output content for given exchange record." + ) + # Check related jobs + self.assertEqual(created, self._get_related_jobs(record)) + with ( + mock.patch.object( + type(self.backend), "_exchange_generate" + ) as mocked_generate, + mock.patch.object(type(self.backend), "_validate_data") as mocked_validate, + ): + mocked_generate.return_value = EDIExchangeActionResult.from_result( + "filecontent" + ) + mocked_validate.return_value = None + res = job.perform() + self.assertEqual(res, "Exchange data generated") + self.assertEqual(record.edi_exchange_state, "output_pending") + job = self.backend.with_delay().exchange_send(record) + created = job_counter.search_created() + with mock.patch.object(type(self.backend), "_exchange_send") as mocked: + mocked.return_value = EDIExchangeActionResult.from_result(message) + res = job.perform() + self.assertEqual(res, expected_message) + self.assertEqual(record.edi_exchange_state, "output_sent") + self.assertEqual(created[0].name, "Send exchange file.") + # Check related jobs + record.invalidate_recordset() + self.assertEqual(created, self._get_related_jobs(record)) + + def test_output_with_specific_return(self): + self._test_output_return("specific return message", "Exchange sent") + + def test_output_no_return(self): + self._test_output_return("", "Exchange sent") + + def test_output_fail_retry(self): + job_counter = self.job_counter() + vals = { + "model": self.partner._name, + "res_id": self.partner.id, + "edi_exchange_state": "output_pending", + } + record = self.backend.create_record("test_csv_output", vals) + record._set_file_content("ABC") + job = self.backend.with_delay().exchange_send(record) + job_counter.search_created() + with mock.patch.object(type(self.backend), "_exchange_send") as mocked: + mocked.side_effect = ReqConnectionError("Connection broken") + with self.assertRaises(RetryableJobError): + job.perform() + + def test_input(self): + job_counter = self.job_counter() + vals = { + "model": self.partner._name, + "res_id": self.partner.id, + } + record = self.backend.create_record("test_csv_input", vals) + job = self.backend.with_delay().exchange_receive(record) + created = job_counter.search_created() + self.assertEqual(len(created), 1) + self.assertEqual(created.name, "Retrieve an incoming document.") + # Check related jobs + self.assertEqual(created, self._get_related_jobs(record)) + with ( + mock.patch.object( + type(self.backend), "_exchange_receive" + ) as mocked_receive, + mock.patch.object(type(self.backend), "_validate_data") as mocked_validate, + ): + mocked_receive.return_value = EDIExchangeActionResult.from_result( + "filecontent" + ) + mocked_validate.return_value = None + res = job.perform() + # the state is not input_pending hence there's nothing to do + self.assertEqual(res, "Nothing to do. Likely already received.") + record.edi_exchange_state = "input_pending" + res = job.perform() + # the state is not input_pending hence there's nothing to do + self.assertEqual(res, "Exchange received successfully") + self.assertEqual(record.edi_exchange_state, "input_received") + job = self.backend.with_delay().exchange_process(record) + created = job_counter.search_created() + self.assertEqual(created[0].name, "Process an incoming document.") + + def test_input_processed_error(self): + vals = { + "model": self.partner._name, + "res_id": self.partner.id, + "edi_exchange_state": "input_received", + } + record = self.backend.create_record("test_csv_input", vals) + record._set_file_content("ABC") + # Process `input_received` records + job_counter = self.job_counter() + self.backend._check_input_exchange_sync() + created = job_counter.search_created() + # Create job + self.assertEqual(len(created), 1) + record.edi_exchange_state = "input_processed_error" + # Don't re-process `input_processed_error` records + self.backend._check_input_exchange_sync() + new_created = job_counter.search_created() - created + # Should not create new job + self.assertEqual(len(new_created), 0) + # Check related jobs + record.invalidate_recordset() + self.assertEqual(created, self._get_related_jobs(record)) + + def test_on_fail_job(self): + vals = { + "model": self.partner._name, + "res_id": self.partner.id, + } + record = self.backend.create_record("test_csv_output", vals) + self.assertEqual(record.edi_exchange_state, "new") + job = record.action_exchange_generate() + exc_vals = { + "exc_info": "Dummy traceback", + "exc_name": "Dummy exception", + "exc_message": "Dummy message", + } + job.on_fail(exc_vals) + self.assertEqual(record.edi_exchange_state, "validate_error") + self.assertEqual( + record.exchange_error, + ": ".join([exc_vals["exc_name"], exc_vals["exc_message"]]), + ) + self.assertEqual(record.exchange_error_traceback, exc_vals["exc_info"]) + + def test_on_fail_job_non_string_message(self): + # The message is the first argument of the exception, which is the + # errno for an OSError, or may be missing altogether. + vals = { + "model": self.partner._name, + "res_id": self.partner.id, + } + record = self.backend.create_record("test_csv_output", vals) + job = record.action_exchange_generate() + job.on_fail( + { + "exc_info": "Dummy traceback", + "exc_name": "PermissionError", + "exc_message": 13, + } + ) + self.assertEqual(record.edi_exchange_state, "validate_error") + self.assertEqual(record.exchange_error, "PermissionError: 13") + job.on_fail( + { + "exc_info": "Dummy traceback", + "exc_name": "JobFoundDead", + "exc_message": None, + } + ) + self.assertEqual(record.exchange_error, "JobFoundDead") + job.on_fail( + { + "exc_info": "Dummy traceback", + "exc_name": None, + "exc_message": "Something went wrong", + } + ) + self.assertEqual(record.exchange_error, "Something went wrong") diff --git a/edi_queue_oca/tests/test_backend_output_jobs.py b/edi_queue_oca/tests/test_backend_output_jobs.py new file mode 100644 index 000000000..593a97ca8 --- /dev/null +++ b/edi_queue_oca/tests/test_backend_output_jobs.py @@ -0,0 +1,96 @@ +# Copyright 2020 ACSONE +# Copyright 2021 Camptocamp +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + + +from odoo.orm.model_classes import add_to_registry +from odoo.tests import tagged + +from odoo.addons.edi_core_oca.tests.common import EDIBackendCommonTestCase +from odoo.addons.queue_job.tests.common import trap_jobs + + +@tagged("-at_install", "post_install") +class EDIBackendTestOutputJobsCase(EDIBackendCommonTestCase): + @classmethod + def _setup_records(cls): # pylint:disable=missing-return + super()._setup_records() + from odoo.addons.edi_core_oca.tests.fake_models import EdiTestExecution + + add_to_registry(cls.registry, EdiTestExecution) + cls.registry._setup_models__(cls.env.cr, ["edi.framework.test.execution"]) + cls.registry.init_models( + cls.env.cr, ["edi.framework.test.execution"], {"models_to_check": True} + ) + cls.addClassCleanup(cls.registry.__delitem__, "edi.framework.test.execution") + model = cls.env["ir.model"]._get("edi.framework.test.execution") + cls.exchange_type_out.generate_model_id = model + cls.exchange_type_out.send_model_id = model + + @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.record.type_id.exchange_file_auto_generate = True + + @classmethod + def _setup_context(cls): + # Re-enable jobs + return dict(super()._setup_context(), queue_job__no_delay=False) + + def test_job(self): + with trap_jobs() as trap: + self.backend._check_output_exchange_sync(record_ids=self.record.ids) + trap.assert_jobs_count(2) + trap.assert_enqueued_job( + self.record.action_exchange_generate, + ) + trap.assert_enqueued_job( + self.record.action_exchange_send, + ) + # No matter how many times we schedule jobs + self.record.action_exchange_generate() + self.record.action_exchange_generate() + self.record.action_exchange_generate() + # identity key should prevent having new jobs for same record same file + trap.assert_jobs_count(2) + # but if we change the content + self.record._set_file_content("something different") + # 1st call will schedule another job + self.record.action_exchange_generate() + # the 2nd one not + self.record.action_exchange_generate() + trap.assert_jobs_count(3) + job = self.record.action_exchange_send() + self.assertEqual(0, job.priority) + trap.assert_jobs_count(4) + + def test_generate_send_chained(self): + """Generating and sending a record queues one chain of two jobs. + + Scenario: + 1. Ask a new output record to be generated and sent. + 2. Run the queued jobs. + Expected: + - A generate job and a send job are queued. + - The send job waits for the generate job and gets the top priority. + - Once both ran, the record has been sent. + """ + with trap_jobs() as trap: + self.record.action_exchange_generate_send_chained() + trap.assert_jobs_count(2) + trap.assert_enqueued_job(self.record.action_exchange_generate) + trap.assert_enqueued_job( + self.record.action_exchange_send, properties={"priority": 0} + ) + generate_job, send_job = sorted( + trap.enqueued_jobs, key=lambda job: job.method_name + ) + self.assertEqual(send_job.depends_on, {generate_job}) + trap.perform_enqueued_jobs() + self.assertRecordValues(self.record, [{"edi_exchange_state": "output_sent"}]) diff --git a/edi_queue_oca/tests/test_exchange_type.py b/edi_queue_oca/tests/test_exchange_type.py new file mode 100644 index 000000000..27dd5f096 --- /dev/null +++ b/edi_queue_oca/tests/test_exchange_type.py @@ -0,0 +1,60 @@ +# Copyright 2026 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from datetime import datetime + +from freezegun import freeze_time + +from .common import EDIQueueCommonTestCase + + +class EDIExchangeTypeTestCase(EDIQueueCommonTestCase): + def setUp(self): + super().setUp() + self.record = self._make_record() + + def _get_job_eta(self, record): + return self._get_delayed(record).delayable.eta + + def test_job_eta_disabled_no_eta_applied(self): + """job_eta_enabled=False: no ETA is set regardless of value.""" + self.exchange_type_in.job_eta_hour = "22" + self.exchange_type_in.job_eta_minute = "00" + # job_eta_enabled defaults to False + self.assertIsNone(self._get_job_eta(self.record)) + + @freeze_time("2024-01-15 20:00:00") + def test_job_eta_scheduled_same_day(self): + """ETA not yet reached today: job lands on the same calendar day.""" + self.env.user.tz = "UTC" + self.exchange_type_in.job_eta_enabled = True + self.exchange_type_in.job_eta_hour = "22" + self.exchange_type_in.job_eta_minute = "00" + self.assertEqual( + self._get_job_eta(self.record), + datetime(2024, 1, 15, 22, 0, 0), # noqa: DTZ001 + ) + + @freeze_time("2024-01-15 23:00:00") + def test_job_eta_scheduled_next_day(self): + """ETA already passed today: job rolls over to the next calendar day.""" + self.env.user.tz = "UTC" + self.exchange_type_in.job_eta_enabled = True + self.exchange_type_in.job_eta_hour = "22" + self.exchange_type_in.job_eta_minute = "00" + self.assertEqual( + self._get_job_eta(self.record), + datetime(2024, 1, 16, 22, 0, 0), # noqa: DTZ001 + ) + + @freeze_time("2024-01-15 20:00:00") + def test_job_eta_midnight_schedules_next_day(self): + """Midnight (00:00) schedules for the next 00:00 in user TZ.""" + self.env.user.tz = "UTC" + self.exchange_type_in.job_eta_enabled = True + self.exchange_type_in.job_eta_hour = "00" + self.exchange_type_in.job_eta_minute = "00" + self.assertEqual( + self._get_job_eta(record=self.record), + datetime(2024, 1, 16, 0, 0, 0), # noqa: DTZ001 + ) diff --git a/edi_queue_oca/tests/test_record.py b/edi_queue_oca/tests/test_record.py new file mode 100644 index 000000000..9d567638d --- /dev/null +++ b/edi_queue_oca/tests/test_record.py @@ -0,0 +1,62 @@ +# Copyright 2020 ACSONE +# Copyright 2022 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from datetime import UTC, datetime +from zoneinfo import ZoneInfo + +from odoo.addons.queue_job.delay import DelayableRecordset + +from .common import EDIQueueCommonTestCase + + +class EDIRecordTestCase(EDIQueueCommonTestCase): + def test_with_delay_override(self): + record = self._make_record() + parent_channel = self.env["queue.job.channel"].create( + { + "name": "parent_test_chan", + "parent_id": self.env.ref("queue_job.channel_root").id, + } + ) + channel = self.env["queue.job.channel"].create( + {"name": "test_chan", "parent_id": parent_channel.id} + ) + self.exchange_type_in.job_channel_id = channel + self.exchange_type_in.job_priority = 5 + # re-enable job delayed feature + delayed = self._get_delayed(record) + self.assertTrue(isinstance(delayed, DelayableRecordset)) + self.assertEqual(delayed.recordset, record) + self.assertEqual(delayed.delayable.channel, "root.parent_test_chan.test_chan") + self.assertEqual(delayed.delayable.priority, 5) + + def test_with_delay_job_eta_applied(self): + record = self._make_record() + parent_channel = self.env["queue.job.channel"].create( + { + "name": "parent_test_chan", + "parent_id": self.env.ref("queue_job.channel_root").id, + } + ) + channel = self.env["queue.job.channel"].create( + {"name": "test_chan", "parent_id": parent_channel.id} + ) + self.exchange_type_in.job_channel_id = channel + self.exchange_type_in.job_priority = 5 + self.exchange_type_in.job_eta_enabled = True + self.exchange_type_in.job_eta_hour = "22" + self.exchange_type_in.job_eta_minute = "00" + delayed = self._get_delayed(record) + job_eta = delayed.delayable.eta + user_tz = ZoneInfo(self.env.user.tz or "UTC") + target_22h_user = datetime.now(user_tz).replace( + hour=22, minute=0, second=0, microsecond=0 + ) + expected_eta = target_22h_user.astimezone(UTC).replace(tzinfo=None) + self.assertTrue(isinstance(delayed, DelayableRecordset)) + self.assertEqual(delayed.recordset, record) + self.assertEqual(job_eta, expected_eta) + self.assertEqual(delayed.delayable.channel, "root.parent_test_chan.test_chan") + self.assertEqual(delayed.delayable.priority, 5) diff --git a/edi_queue_oca/utils.py b/edi_queue_oca/utils.py new file mode 100644 index 000000000..3b100bfd6 --- /dev/null +++ b/edi_queue_oca/utils.py @@ -0,0 +1,16 @@ +# Copyright 2020 ACSONE SA +# Copyright 2023 Camptocamp +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + + +from odoo.addons.queue_job.job import identity_exact_hasher + + +def exchange_record_job_identity_exact(job_): + hasher = identity_exact_hasher(job_) + # Include files checksum + hasher.update( + str(sorted(job_.recordset.mapped("exchange_filechecksum"))).encode("utf-8") + ) + return hasher.hexdigest() diff --git a/edi_queue_oca/views/edi_exchange_record.xml b/edi_queue_oca/views/edi_exchange_record.xml new file mode 100644 index 000000000..123f51020 --- /dev/null +++ b/edi_queue_oca/views/edi_exchange_record.xml @@ -0,0 +1,28 @@ + + + + + edi.exchange.record + + +
+ +
+
+
+
diff --git a/edi_queue_oca/views/edi_exchange_type.xml b/edi_queue_oca/views/edi_exchange_type.xml new file mode 100644 index 000000000..7118085b4 --- /dev/null +++ b/edi_queue_oca/views/edi_exchange_type.xml @@ -0,0 +1,35 @@ + + + + + edi.exchange.type + + + + + + + + + + + + + + + + + diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 000000000..c6fbe7ae5 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,5 @@ +odoo-addon-base_edi @ git+https://github.com/ForgeFlow/edi@20.0-mig-base_edi#subdirectory=base_edi +odoo-addon-component @ git+https://github.com/ForgeFlow/connector@20.0-mig-component#subdirectory=component +odoo-addon-component_event @ git+https://github.com/ForgeFlow/connector@20.0-mig-component_event#subdirectory=component_event +odoo-addon-edi_core_oca @ git+https://github.com/ForgeFlow/edi-framework@20.0-mig-edi_core_oca#subdirectory=edi_core_oca +odoo-addon-queue_job @ git+https://github.com/OCA/queue@refs/pull/999/head#subdirectory=queue_job