diff --git a/setup/stock_release_channel_shipment_advice_deliver/odoo/addons/stock_release_channel_shipment_advice_deliver b/setup/stock_release_channel_shipment_advice_deliver/odoo/addons/stock_release_channel_shipment_advice_deliver new file mode 120000 index 0000000000..649514b33e --- /dev/null +++ b/setup/stock_release_channel_shipment_advice_deliver/odoo/addons/stock_release_channel_shipment_advice_deliver @@ -0,0 +1 @@ +../../../../stock_release_channel_shipment_advice_deliver \ No newline at end of file diff --git a/setup/stock_release_channel_shipment_advice_deliver/setup.py b/setup/stock_release_channel_shipment_advice_deliver/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/stock_release_channel_shipment_advice_deliver/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/stock_release_channel_shipment_advice_deliver/README.rst b/stock_release_channel_shipment_advice_deliver/README.rst new file mode 100644 index 0000000000..6f01031b0f --- /dev/null +++ b/stock_release_channel_shipment_advice_deliver/README.rst @@ -0,0 +1,90 @@ +============================================= +Stock Release Channel Shipment Advice Deliver +============================================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:3be993cbf406a294ea53a79355920e76f18b7430227b696ac8b052fa7d45b673 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fwms-lightgray.png?logo=github + :target: https://github.com/OCA/wms/tree/16.0/stock_release_channel_shipment_advice_deliver + :alt: OCA/wms +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/wms-16-0/wms-16-0-stock_release_channel_shipment_advice_deliver + :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/wms&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module adds an action to the release channel to automate the delivery of +its shippings through shipment advices. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +A "Deliver" button for locked release channels is added. + +When this new button is pressed: + - The release channel change its state to "delivering". + - A background task (job queue) is planned to: + - Validate the shippings related to the release channel. + - Create the shipment advices. + - Processes the shipment advices. + +At the end of the background task: + - The release channel status moves to "delivered" if no errors are detected. + - Otherwise appropriate error messages are displayed and a button to retry + is shown to the user. + +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 +~~~~~~~ + +* ACSONE SA/NV +* BCIM + +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/wms `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/stock_release_channel_shipment_advice_deliver/__init__.py b/stock_release_channel_shipment_advice_deliver/__init__.py new file mode 100644 index 0000000000..aee8895e7a --- /dev/null +++ b/stock_release_channel_shipment_advice_deliver/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizards diff --git a/stock_release_channel_shipment_advice_deliver/__manifest__.py b/stock_release_channel_shipment_advice_deliver/__manifest__.py new file mode 100644 index 0000000000..20c26d9bc9 --- /dev/null +++ b/stock_release_channel_shipment_advice_deliver/__manifest__.py @@ -0,0 +1,27 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Stock Release Channel Shipment Advice Deliver", + "summary": """This module adds an action to the release channel to + automate the delivery of its shippings.""", + "author": "ACSONE SA/NV, BCIM, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/wms", + "category": "Warehouse Management", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "depends": [ + "stock_release_channel", + "queue_job", + "stock_release_channel_shipment_advice", + "web_notify", + "stock_available_to_promise_release", + ], + "data": [ + "security/stock_release_channel_deliver_check_wizard.xml", + "wizards/stock_release_channel_deliver_check_wizard.xml", + "data/queue_job_channel.xml", + "data/queue_job_function.xml", + "views/stock_release_channel.xml", + ], +} diff --git a/stock_release_channel_shipment_advice_deliver/data/queue_job_channel.xml b/stock_release_channel_shipment_advice_deliver/data/queue_job_channel.xml new file mode 100644 index 0000000000..c32d5c5882 --- /dev/null +++ b/stock_release_channel_shipment_advice_deliver/data/queue_job_channel.xml @@ -0,0 +1,11 @@ + + + + + stock_picking_deliver + + + diff --git a/stock_release_channel_shipment_advice_deliver/data/queue_job_function.xml b/stock_release_channel_shipment_advice_deliver/data/queue_job_function.xml new file mode 100644 index 0000000000..65724046a6 --- /dev/null +++ b/stock_release_channel_shipment_advice_deliver/data/queue_job_function.xml @@ -0,0 +1,21 @@ + + + + + + _action_deliver + + + + + + _auto_process + + + diff --git a/stock_release_channel_shipment_advice_deliver/i18n/fr.po b/stock_release_channel_shipment_advice_deliver/i18n/fr.po new file mode 100644 index 0000000000..fad10ee8d2 --- /dev/null +++ b/stock_release_channel_shipment_advice_deliver/i18n/fr.po @@ -0,0 +1,289 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * stock_release_channel_shipment_advice_deliver +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-01-23 15:46+0000\n" +"PO-Revision-Date: 2024-01-23 15:46+0000\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: stock_release_channel_shipment_advice_deliver +#. odoo-python +#: code:addons/stock_release_channel_shipment_advice_deliver/models/stock_release_channel.py:0 +#, python-format +msgid "Action 'Deliver' is not allowed for the channel %(name)s." +msgstr "L'action 'Livrer' n'est pas autorisée pour le canal %(name)s." + +#. module: stock_release_channel_shipment_advice_deliver +#. odoo-python +#: code:addons/stock_release_channel_shipment_advice_deliver/models/stock_release_channel.py:0 +#, python-format +msgid "Action 'Delivered' is not allowed for channel %(name)s." +msgstr "L'action 'Livré' n'est pas autorisée pour le canal %(name)s." + +#. module: stock_release_channel_shipment_advice_deliver +#. odoo-python +#: code:addons/stock_release_channel_shipment_advice_deliver/models/stock_release_channel.py:0 +#, python-format +msgid "Action 'Delivering Error' is not allowed for channel %(name)s." +msgstr "L'action 'Erreur de livraison' n'est pas autorisée pour le canal %(name)s." + +#. module: stock_release_channel_shipment_advice_deliver +#. odoo-python +#: code:addons/stock_release_channel_shipment_advice_deliver/models/stock_release_channel.py:0 +#, python-format +msgid "" +"An error occurred in the delivery background task for the channel %(name)s" +msgstr "" +"Une erreur s'est produite dans la tâche d'arrière-plan de livraison pour le " +"canal %(name)s." + +#. module: stock_release_channel_shipment_advice_deliver +#. odoo-python +#: code:addons/stock_release_channel_shipment_advice_deliver/models/stock_release_channel.py:0 +#, python-format +msgid "" +"An error occurred while processing the delivery automatically:\n" +"- %(related_object_name)s: %(error)s" +msgstr "" +"Une erreur s'est produite lors du traitement automatique de la livraison:\n" +"- %(related_object_name)s: %(error)s" + +#. module: stock_release_channel_shipment_advice_deliver +#. odoo-python +#: code:addons/stock_release_channel_shipment_advice_deliver/models/shipment_advice.py:0 +#: code:addons/stock_release_channel_shipment_advice_deliver/models/stock_release_channel.py:0 +#, python-format +msgid "Automatically process the shipment advice %(name)s." +msgstr "Traiter automatiquement l'avis d'expédition %(name)s." + +#. module: stock_release_channel_shipment_advice_deliver +#: model_terms:ir.ui.view,arch_db:stock_release_channel_shipment_advice_deliver.stock_release_channel_shipment_advice_deliver_check_wizard_form_view +msgid "Cancel the deliver" +msgstr "Annuler la livraison" + +#. module: stock_release_channel_shipment_advice_deliver +#. odoo-python +#: code:addons/stock_release_channel_shipment_advice_deliver/models/stock_release_channel.py:0 +#, python-format +msgid "Confirm delivery" +msgstr "Confirmer la livraison" + +#. module: stock_release_channel_shipment_advice_deliver +#: model:ir.model.fields,field_description:stock_release_channel_shipment_advice_deliver.field_stock_release_channel_shipment_advice_deliver_check_wizard__create_uid +msgid "Created by" +msgstr "Créé par" + +#. module: stock_release_channel_shipment_advice_deliver +#: model:ir.model.fields,field_description:stock_release_channel_shipment_advice_deliver.field_stock_release_channel_shipment_advice_deliver_check_wizard__create_date +msgid "Created on" +msgstr "Créé le" + +#. module: stock_release_channel_shipment_advice_deliver +#: model_terms:ir.ui.view,arch_db:stock_release_channel_shipment_advice_deliver.stock_release_channel_form_view +msgid "Deliver" +msgstr "Livrer" + +#. module: stock_release_channel_shipment_advice_deliver +#: model_terms:ir.ui.view,arch_db:stock_release_channel_shipment_advice_deliver.stock_release_channel_shipment_advice_deliver_check_wizard_form_view +msgid "Deliver any way" +msgstr "Livrer quand même" + +#. module: stock_release_channel_shipment_advice_deliver +#: model:ir.model.fields.selection,name:stock_release_channel_shipment_advice_deliver.selection__stock_release_channel__state__delivered +msgid "Delivered" +msgstr "Livré" + +#. module: stock_release_channel_shipment_advice_deliver +#: model:ir.model.fields.selection,name:stock_release_channel_shipment_advice_deliver.selection__stock_release_channel__state__delivering +#: model_terms:ir.ui.view,arch_db:stock_release_channel_shipment_advice_deliver.stock_release_channel_search_view +msgid "Delivering" +msgstr "En cours de livraison" + +#. module: stock_release_channel_shipment_advice_deliver +#: model:ir.model.fields,field_description:stock_release_channel_shipment_advice_deliver.field_stock_release_channel__delivering_error +#: model:ir.model.fields.selection,name:stock_release_channel_shipment_advice_deliver.selection__stock_release_channel__state__delivering_error +msgid "Delivering Error" +msgstr "Erreur de livraison" + +#. module: stock_release_channel_shipment_advice_deliver +#. odoo-python +#: code:addons/stock_release_channel_shipment_advice_deliver/models/stock_release_channel.py:0 +#, python-format +msgid "Delivering release channel %(name)s." +msgstr "Livraison du canal de livraison %(name)s" + +#. module: stock_release_channel_shipment_advice_deliver +#: model:ir.model.fields,field_description:stock_release_channel_shipment_advice_deliver.field_stock_release_channel_shipment_advice_deliver_check_wizard__display_name +msgid "Display Name" +msgstr "" + +#. module: stock_release_channel_shipment_advice_deliver +#: model:ir.model.fields,field_description:stock_release_channel_shipment_advice_deliver.field_stock_release_channel_shipment_advice_deliver_check_wizard__id +msgid "ID" +msgstr "" + +#. module: stock_release_channel_shipment_advice_deliver +#: model:ir.model.fields,field_description:stock_release_channel_shipment_advice_deliver.field_stock_release_channel__in_process_shipment_advice_ids +msgid "In Process Shipment Advice" +msgstr "Avis d'expédition En cours" + +#. module: stock_release_channel_shipment_advice_deliver +#: model:ir.model.fields,field_description:stock_release_channel_shipment_advice_deliver.field_shipment_advice__in_release_channel_auto_process +msgid "In Release Channel Auto Process" +msgstr "" + +#. module: stock_release_channel_shipment_advice_deliver +#: model:ir.model.fields,field_description:stock_release_channel_shipment_advice_deliver.field_stock_release_channel__is_action_deliver_allowed +msgid "Is Action Deliver Allowed" +msgstr "Action Livrer Permise" + +#. module: stock_release_channel_shipment_advice_deliver +#: model:ir.model.fields,field_description:stock_release_channel_shipment_advice_deliver.field_stock_release_channel__is_action_delivered_allowed +msgid "Is Action Delivered Allowed" +msgstr "Action Livré Permise" + +#. module: stock_release_channel_shipment_advice_deliver +#: model:ir.model.fields,field_description:stock_release_channel_shipment_advice_deliver.field_stock_release_channel__is_action_delivering_error_allowed +msgid "Is Action Delivering Error Allowed" +msgstr "Action Erreur de livraison Permise" + +#. module: stock_release_channel_shipment_advice_deliver +#: model:ir.model.fields,field_description:stock_release_channel_shipment_advice_deliver.field_stock_release_channel_shipment_advice_deliver_check_wizard____last_update +msgid "Last Modified on" +msgstr "Dernière modification le" + +#. module: stock_release_channel_shipment_advice_deliver +#: model:ir.model.fields,field_description:stock_release_channel_shipment_advice_deliver.field_stock_release_channel_shipment_advice_deliver_check_wizard__write_uid +msgid "Last Updated by" +msgstr "Dernière modification par" + +#. module: stock_release_channel_shipment_advice_deliver +#: model:ir.model.fields,field_description:stock_release_channel_shipment_advice_deliver.field_stock_release_channel_shipment_advice_deliver_check_wizard__write_date +msgid "Last Updated on" +msgstr "Dernière modification le" + +#. module: stock_release_channel_shipment_advice_deliver +#. odoo-python +#: code:addons/stock_release_channel_shipment_advice_deliver/models/stock_release_channel.py:0 +#, python-format +msgid "No picking to deliver for channel %(name)s." +msgstr "Aucun picking à livrer pour le canal %(name)s." + +#. module: stock_release_channel_shipment_advice_deliver +#. odoo-python +#: code:addons/stock_release_channel_shipment_advice_deliver/models/stock_release_channel.py:0 +#, python-format +msgid "" +"One of the delivery for channel %(name)s is waiting on another printed transfer. \n" +"Please finish it manually or cancel its start to be able to deliver.\n" +"%(pickings)s" +msgstr "" +"Une des livraison pour le canal %(name)s est en attente d'un autre transfert imprimé.\n" +"Veuillez le terminer manuellement ou annuler son démarrage pour pouvoir livrer.\n" +"%(pickings)s" + +#. module: stock_release_channel_shipment_advice_deliver +#: model:ir.model.fields,field_description:stock_release_channel_shipment_advice_deliver.field_stock_release_channel_shipment_advice_deliver_check_wizard__release_channel_id +msgid "Release Channel" +msgstr "Canaux de livraison" + +#. module: stock_release_channel_shipment_advice_deliver +#: model:ir.model,name:stock_release_channel_shipment_advice_deliver.model_shipment_advice +msgid "Shipment Advice" +msgstr "Note d'envoi" + +#. module: stock_release_channel_shipment_advice_deliver +#. odoo-python +#: code:addons/stock_release_channel_shipment_advice_deliver/models/stock_release_channel.py:0 +#, python-format +msgid "" +"Some deliveries have not been prepared but cannot be unreleased.\n" +"\n" +"%(shipping)s" +msgstr "" +"Certaines livraisons n'ont pas été préparées mais ne peuvent pas être délibérées.\n" +"\n" +"%(shipping)s" + +#. module: stock_release_channel_shipment_advice_deliver +#: model:ir.model.fields,field_description:stock_release_channel_shipment_advice_deliver.field_stock_release_channel__state +msgid "State" +msgstr "État" + +#. module: stock_release_channel_shipment_advice_deliver +#: model:ir.model,name:stock_release_channel_shipment_advice_deliver.model_stock_picking +msgid "Stock Picking" +msgstr "" + +#. module: stock_release_channel_shipment_advice_deliver +#: model_terms:ir.ui.view,arch_db:stock_release_channel_shipment_advice_deliver.stock_release_channel_shipment_advice_deliver_check_wizard_form_view +msgid "Stock Release Channel Deliver Check" +msgstr "" + +#. module: stock_release_channel_shipment_advice_deliver +#: model:ir.model,name:stock_release_channel_shipment_advice_deliver.model_stock_release_channel +msgid "Stock Release Channels" +msgstr "Canaux de livraison" + +#. module: stock_release_channel_shipment_advice_deliver +#: model:ir.model.fields,help:stock_release_channel_shipment_advice_deliver.field_shipment_advice__in_release_channel_auto_process +msgid "" +"Technical field to flag shipment advice that are in a release channel auto-" +"process" +msgstr "" + +#. module: stock_release_channel_shipment_advice_deliver +#. odoo-python +#: code:addons/stock_release_channel_shipment_advice_deliver/models/stock_release_channel.py:0 +#, python-format +msgid "The delivery background task is done for the channel %(name)s" +msgstr "La tâche d'arrière-plan de livraison est effectuée pour le canal %(name)s." + +#. module: stock_release_channel_shipment_advice_deliver +#: model:ir.model.fields,help:stock_release_channel_shipment_advice_deliver.field_stock_release_channel__state +msgid "" +"The state allows you to control the availability of the release channel.\n" +"* Open: Manual and automatic picking assignment to the release is effective and release operations are allowed.\n" +" * Locked: Release operations are forbidden. (Assignement processes are still working)\n" +"* Delivering: A background task is running to automatically deliver ready shipments\n" +"* Delivering Error: An error occurred in the delivery background task\n" +"* Delivered: Ready transfers are delivered\n" +"* Asleep: Assigned pickings not processed are unassigned from the release channel.\n" +msgstr "" +"L'état vous permet de contrôler la disponibilité du canal de livraison.\n" +"* Ouvert: l'affectation manuelle et automatique des transferts au canal de " +"livraison est effective et les opérations de préparation sont autorisées.\n" +"* Verrouillé: les opérations de préparation sont interdites. (Les processus " +"d'affectation fonctionnent toujours)\n" +"* Livraison : une tâche en arrière-plan est en cours d'exécution pour livrer " +"automatiquement les transferts\n" +"* Erreur de livraison: une erreur s'est produite dans la tâche d'arrière-" +"plan de livraison\n" +"* Livré: les transferts prêts sont livrés\n" +"* Endormi: les transferts affectés non traités sont désaffectés du canal de " +"livraison.\n" + +#. module: stock_release_channel_shipment_advice_deliver +#: model_terms:ir.ui.view,arch_db:stock_release_channel_shipment_advice_deliver.stock_release_channel_shipment_advice_deliver_check_wizard_form_view +msgid "" +"There are some preparations that have not been completed.\n" +" If you choose to proceed, these preparations will be unreleased.
\n" +" Are you sure you want to proceed with the delivery?" +msgstr "" +"Il y a des transferts qui n'ont pas été finalisés.\n +" Si vous choisissez de continuer, ces transferts vont être délibérés.
\n" +" Etes-vous sûr de vouloir lancer la livraison?" + +#. module: stock_release_channel_shipment_advice_deliver +#: model:ir.model,name:stock_release_channel_shipment_advice_deliver.model_stock_release_channel_shipment_advice_deliver_check_wizard +msgid "stock release channel deliver check wizard" +msgstr "" diff --git a/stock_release_channel_shipment_advice_deliver/models/__init__.py b/stock_release_channel_shipment_advice_deliver/models/__init__.py new file mode 100644 index 0000000000..de94e10bf6 --- /dev/null +++ b/stock_release_channel_shipment_advice_deliver/models/__init__.py @@ -0,0 +1,3 @@ +from . import stock_release_channel +from . import shipment_advice +from . import stock_picking diff --git a/stock_release_channel_shipment_advice_deliver/models/shipment_advice.py b/stock_release_channel_shipment_advice_deliver/models/shipment_advice.py new file mode 100644 index 0000000000..ee4af8c513 --- /dev/null +++ b/stock_release_channel_shipment_advice_deliver/models/shipment_advice.py @@ -0,0 +1,106 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class ShipmentAdvice(models.Model): + _inherit = "shipment.advice" + + in_release_channel_auto_process = fields.Boolean( + readonly=True, + help="Technical field to flag shipment advice that are in a release channel " + "auto-process", + index=True, + ) + + @property + def _is_auto_process(self) -> bool: + """We consider that a shipment advice created for a release channel in 'delivering'. + + state should be processed automatically + In this way we avoid that the release channel keep watching the shipment advice + creation and process them. Each shipment advice manage its own process and call + the release channel to notify it when it's done. + """ + return self.release_channel_id and self.release_channel_id.state in ( + "delivering", + "delivering_error", + ) + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + for rec in records: + if rec._is_auto_process: + rec.with_delay( + description=_( + "Automatically process the shipment advice %(name)s.", + name=rec.name, + ) + )._auto_process() + return records + + def _auto_process(self): + self.ensure_one() + if not self._is_auto_process: + return False + if not self.arrival_date: + self.arrival_date = fields.Date.context_today(self) + self.in_release_channel_auto_process = True + try: + with self.env.cr.savepoint(): + move_lines = self.planned_move_ids.move_line_ids + move_lines_to_load = move_lines.filtered( + lambda ml: ml.state not in ("done", "cancel") + ) + move_lines_to_load._load_in_shipment(self) + if self.state == "confirmed": + self.action_in_progress() + self.action_done() + except UserError as error: + _logger.error(error) + self.write( + { + "state": "error", + "error_message": self._get_error_message(error, self), + } + ) + self.release_channel_id._shipment_advice_auto_process_notify_error( + self.error_message + ) + return True + + def _postprocess_action_done(self): + res = super()._postprocess_action_done() + if self.state == "error": + return self.release_channel_id._shipment_advice_auto_process_notify_error( + self.error_message + ) + if self.state != "done": + return res + return self.release_channel_id._shipment_advice_auto_process_notify_success() + + def action_done(self): + # If the channel is in error and we try to validate its shipment + # advice, we set the release channel state to delivering.""" + for rec in self: + if ( + rec.state == "error" + and rec.release_channel_id + and rec.release_channel_id.state == "delivering_error" + ): + rec.release_channel_id.state = "delivering" + return super().action_done() + + def action_in_progress(self): + res = super().action_in_progress() + self.release_channel_id.filtered( + "is_action_deliver_allowed" + ).state = "delivering" + return res diff --git a/stock_release_channel_shipment_advice_deliver/models/stock_picking.py b/stock_release_channel_shipment_advice_deliver/models/stock_picking.py new file mode 100644 index 0000000000..6d72d492d6 --- /dev/null +++ b/stock_release_channel_shipment_advice_deliver/models/stock_picking.py @@ -0,0 +1,14 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models +from odoo.osv.expression import AND + + +class StockPicking(models.Model): + _inherit = "stock.picking" + + def _get_release_channel_possible_candidate_domain(self): + self.ensure_one() + domain = [("state", "not in", ("delivering", "delivering_error", "delivered"))] + return AND([super()._get_release_channel_possible_candidate_domain(), domain]) diff --git a/stock_release_channel_shipment_advice_deliver/models/stock_release_channel.py b/stock_release_channel_shipment_advice_deliver/models/stock_release_channel.py new file mode 100644 index 0000000000..94929f1f89 --- /dev/null +++ b/stock_release_channel_shipment_advice_deliver/models/stock_release_channel.py @@ -0,0 +1,349 @@ +# Copyright 2023 ACSONE SA/NV +# Copyright 2024 Jacques-Etienne Baudoux (BCIM) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class StockReleaseChannel(models.Model): + _inherit = "stock.release.channel" + + state = fields.Selection( + selection_add=[ + ("delivering", "Delivering"), + ("delivering_error", "Delivering Error"), + ("delivered", "Delivered"), + ], + help="The state allows you to control the availability of the release channel.\n" + "* Open: Manual and automatic picking assignment to the release is effective " + "and release operations are allowed.\n " + "* Locked: Release operations are forbidden. (Assignement processes are " + "still working)\n" + "* Delivering: A background task is running to automatically deliver ready shipments\n" + "* Delivering Error: An error occurred in the delivery background task\n" + "* Delivered: Ready transfers are delivered\n" + "* Asleep: Assigned pickings not processed are unassigned from the release " + "channel.\n", + ) + + is_action_deliver_allowed = fields.Boolean( + compute="_compute_is_action_deliver_allowed" + ) + is_action_delivering_error_allowed = fields.Boolean( + compute="_compute_is_action_delivering_error_allowed" + ) + is_action_delivered_allowed = fields.Boolean( + compute="_compute_is_action_delivered_allowed" + ) + delivering_error = fields.Text(readonly=True) + in_process_shipment_advice_ids = fields.One2many( + "shipment.advice", compute="_compute_in_process_shipment_advice_ids" + ) + auto_deliver = fields.Boolean() + at_deliver_to_unrelease_shipping_move_ids = fields.One2many( + comodel_name="stock.move", + compute="_compute_at_deliver_to_unrelease_shipping_move_ids", + help="These are the shipping stock moves we need to unrelease.", + ) + + @api.depends("shipment_advice_ids") + def _compute_in_process_shipment_advice_ids(self): + shipment_advice_model = self.env["shipment.advice"] + for rec in self: + rec.in_process_shipment_advice_ids = shipment_advice_model.search( + [ + ("in_release_channel_auto_process", "=", True), + ("release_channel_id", "=", rec.id), + ] + ) + + @api.depends("state", "picking_to_plan_ids", "shipment_planning_method") + def _compute_is_action_deliver_allowed(self): + for rec in self: + rec.is_action_deliver_allowed = ( + rec.state + in ( + "locked", + "delivering_error", + ) + and bool(rec.picking_to_plan_ids) + and rec.shipment_planning_method != "none" + and rec.auto_deliver + ) + + @api.depends("state") + def _compute_is_action_delivering_error_allowed(self): + for rec in self: + rec.is_action_delivering_error_allowed = rec.state == "delivering" + + @api.depends("state") + def _compute_is_action_delivered_allowed(self): + for rec in self: + rec.is_action_delivered_allowed = rec.state == "delivering" + + def _deliver_check_has_picking_planned(self): + self.ensure_one() + if not self.picking_to_plan_ids: + raise UserError( + _("No picking to deliver for channel %(name)s.", name=self.name) + ) + + def _check_is_action_delivering_error_allowed(self): + for rec in self: + if not rec.is_action_delivering_error_allowed: + raise UserError( + _( + "Action 'Delivering Error' is not allowed for channel %(name)s.", + name=rec.name, + ) + ) + + def _check_is_action_delivered_allowed(self): + for rec in self: + if not rec.is_action_delivered_allowed: + raise UserError( + _( + "Action 'Delivered' is not allowed for channel %(name)s.", + name=rec.name, + ) + ) + + def _picking_moves_to_unrelease(self): + self.ensure_one() + return self.env["stock.move"].search( + [ + ("picking_type_id.code", "=", "internal"), + ("picking_id.release_channel_id", "=", self.id), + ("state", "not in", ("cancel", "done")), + ] + ) + + def _get_at_deliver_to_unrelease_shipping_moves_domain(self) -> list: + """ + Return the domain to search moves to unrelease at deliver. + """ + return [ + ("picking_id.release_channel_id", "in", self.ids), + ("picking_type_id.code", "=", "outgoing"), + ("need_release", "=", False), + ("state", "in", ("waiting", "partially_available")), + ("rule_id.available_to_promise_defer_pull", "=", True), + ] + + @api.depends("picking_ids") + def _compute_at_deliver_to_unrelease_shipping_move_ids(self): + """ + Compute the moves to unrelease at these channels delivering + """ + # Get moves for all channels in self + moves = self.env["stock.move"].search( + self._get_at_deliver_to_unrelease_shipping_moves_domain() + ) + for channel in self: + channel_moves = moves.filtered( + lambda move, channel=channel: move.picking_id.release_channel_id.id + == channel.id + ) + to_unrelease_moves = channel_moves + for move in channel_moves: + for internal_moves in move.move_orig_ids._get_chained_moves_iterator( + "move_orig_ids" + ): + if any(im.state not in ("cancel", "done") for im in internal_moves): + break + else: + to_unrelease_moves -= move + channel.at_deliver_to_unrelease_shipping_move_ids = to_unrelease_moves + + def _deliver_cleanup_printed(self): + """Unset "printed" on non ready transfer + + Otherwise the unrelease will not be allowed + """ + pickings = self.picking_chain_ids.move_ids.filtered( + lambda m: m.state in ("confirmed", "partially_available") + and m.picking_id.state != "assigned" + and m.picking_id.printed + ).picking_id + pickings.printed = False + + def _deliver_check_moves_in_progress(self, moves_to_unrelease) -> None: + """ + This checks that the moves chain is not in progress (printed or quantity_done) + """ + iterator = moves_to_unrelease._get_chained_moves_iterator("move_orig_ids") + next(iterator) # skip the current move + for origin_moves in iterator: + in_progress_moves = origin_moves._in_progress_for_unrelease() + if in_progress_moves: + raise UserError( + _( + "One of the delivery for channel %(name)s is waiting on " + "another transfer. \nPlease finish it manually or " + "cancel its start and done quantities to be able to deliver.\n" + "%(pickings)s", + name=self.name, + pickings=", ".join(in_progress_moves.mapped("picking_id.name")), + ) + ) + + def action_deliver(self): + self.ensure_one() + if not self.is_action_deliver_allowed: + raise UserError( + _( + "Action 'Deliver' is not allowed for the channel %(name)s.", + name=self.name, + ) + ) + self._deliver_check_has_picking_planned() + self._deliver_cleanup_printed() + moves_to_unrelease = self.at_deliver_to_unrelease_shipping_move_ids + if moves_to_unrelease: + self._deliver_check_moves_in_progress(moves_to_unrelease) + if any(not m._is_unreleaseable() for m in moves_to_unrelease): + raise UserError( + _( + "Some deliveries have not been prepared but cannot be unreleased." + "\n\n%(shipping)s", + shipping=", ".join( + moves_to_unrelease.picking_id.mapped("name") + ), + ) + ) + return { + "name": _("Confirm delivery"), + "type": "ir.actions.act_window", + "view_type": "form", + "view_mode": "form", + "res_model": "stock.release.channel.deliver.check.wizard", + "target": "new", + "context": {"default_release_channel_id": self.id, **self.env.context}, + } + self._action_deliver() + return {} + + def _action_deliver(self): + self.write({"state": "delivering", "delivering_error": False}) + self.with_delay( + description=_("Delivering release channel %(name)s.", name=self.name) + )._process_shipments() + + def action_delivering_error(self): + self._check_is_action_delivering_error_allowed() + self.write({"state": "delivering_error"}) + self.env.user.notify_danger( + message=_( + "An error occurred in the delivery background task for the channel %(name)s", + name=self.display_name, + ), + title="Delivering Error", + sticky=True, + ) + + def action_delivered(self): + self._check_is_action_delivered_allowed() + self.write({"state": "delivered"}) + # after deliver, we need to unrelease backorders so they can be assigned + # to release channel later + self.unrelease_backorders() + self.env.user.notify_success( + message=_( + "The delivery background task is done for the channel %(name)s", + name=self.display_name, + ), + title="Delivering done", + sticky=True, + ) + + def _process_shipments(self): + self.ensure_one() + shipment_advice = self.in_process_shipment_advice_ids.filtered( + lambda s: s.state in ("in_progress", "error") + ) + if shipment_advice and len(shipment_advice) == 1: + shipment_advice.with_delay( + description=_( + "Automatically process the shipment advice %(name)s.", + name=shipment_advice.name, + ) + )._auto_process() + else: + self._plan_shipments() + + def action_sleep(self): + self.in_process_shipment_advice_ids.write( + {"in_release_channel_auto_process": False} + ) + return super().action_sleep() + + def _shipment_advice_auto_process_notify_success(self): + self.ensure_one() + shipment_states = set(self.in_process_shipment_advice_ids.mapped("state")) + not_done_states = ["confirmed", "in_progress", "in_process", "error"] + if any(not_done_state in shipment_states for not_done_state in not_done_states): + return + self.action_delivered() + + @api.model + def _get_delivering_error_message(self, error, related_object): + return _( + "An error occurred while processing the delivery automatically:" + "\n- %(related_object_name)s: %(error)s", + related_object_name=related_object.display_name, + error=str(error), + ) + + def _shipment_advice_auto_process_notify_error(self, error_message): + self.ensure_one() + if self.state == "delivering_error": + return + self.action_delivering_error() + self.delivering_error = error_message + + @api.depends("state") + def _compute_is_action_lock_allowed(self): + res = super()._compute_is_action_lock_allowed() + for rec in self: + rec.is_action_lock_allowed = ( + rec.is_action_lock_allowed or rec.state == "delivering_error" + ) + return res + + @api.depends("state") + def _compute_is_action_sleep_allowed(self): + res = super()._compute_is_action_sleep_allowed() + for rec in self: + rec.is_action_sleep_allowed = ( + rec.is_action_sleep_allowed or rec.state == "delivered" + ) + return res + + def unrelease_picking(self): + shipping_moves_to_unrelease = self.at_deliver_to_unrelease_shipping_move_ids + shipping_moves_to_unrelease.unrelease(safe_unrelease=True) + + def unrelease_backorders(self): + backorders = ( + self.in_process_shipment_advice_ids.loaded_picking_ids.backorder_ids + ) + backorders.unrelease(safe_unrelease=True) + + @api.depends("shipment_advice_ids") + def _compute_shipment_advice_to_print_ids(self): + res = super()._compute_shipment_advice_to_print_ids() + for rec in self: + if rec.auto_deliver: + rec.shipment_advice_to_print_ids = fields.first( + rec.in_process_shipment_advice_ids.filtered( + lambda r: r.state == "done" + ).sorted("id", reverse=True) + ) + return res + + @api.model + def _get_print_shipment_allowed_states(self): + res = super()._get_print_shipment_allowed_states() + res.append("delivered") + return res diff --git a/stock_release_channel_shipment_advice_deliver/readme/CONTRIBUTORS b/stock_release_channel_shipment_advice_deliver/readme/CONTRIBUTORS new file mode 100644 index 0000000000..fdd451bee7 --- /dev/null +++ b/stock_release_channel_shipment_advice_deliver/readme/CONTRIBUTORS @@ -0,0 +1,2 @@ +* Souheil Bejaoui +* Jacques-Etienne Baudoux (BCIM) diff --git a/stock_release_channel_shipment_advice_deliver/readme/DESCRIPTION.rst b/stock_release_channel_shipment_advice_deliver/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..94c8aba67e --- /dev/null +++ b/stock_release_channel_shipment_advice_deliver/readme/DESCRIPTION.rst @@ -0,0 +1,2 @@ +This module adds an action to the release channel to automate the delivery of +its shippings through shipment advices. diff --git a/stock_release_channel_shipment_advice_deliver/readme/USAGE.rst b/stock_release_channel_shipment_advice_deliver/readme/USAGE.rst new file mode 100644 index 0000000000..d4a3f32bbe --- /dev/null +++ b/stock_release_channel_shipment_advice_deliver/readme/USAGE.rst @@ -0,0 +1,13 @@ +A "Deliver" button for locked release channels is added. + +When this new button is pressed: + - The release channel change its state to "delivering". + - A background task (job queue) is planned to: + - Validate the shippings related to the release channel. + - Create the shipment advices. + - Processes the shipment advices. + +At the end of the background task: + - The release channel status moves to "delivered" if no errors are detected. + - Otherwise appropriate error messages are displayed and a button to retry + is shown to the user. diff --git a/stock_release_channel_shipment_advice_deliver/security/stock_release_channel_deliver_check_wizard.xml b/stock_release_channel_shipment_advice_deliver/security/stock_release_channel_deliver_check_wizard.xml new file mode 100644 index 0000000000..a23d28b51d --- /dev/null +++ b/stock_release_channel_shipment_advice_deliver/security/stock_release_channel_deliver_check_wizard.xml @@ -0,0 +1,19 @@ + + + + + + stock.release.channel.deliver.check.wizard access + + + + + + + + + diff --git a/stock_release_channel_shipment_advice_deliver/static/description/index.html b/stock_release_channel_shipment_advice_deliver/static/description/index.html new file mode 100644 index 0000000000..dd0c209704 --- /dev/null +++ b/stock_release_channel_shipment_advice_deliver/static/description/index.html @@ -0,0 +1,445 @@ + + + + + + +Stock Release Channel Shipment Advice Deliver + + + +
+

Stock Release Channel Shipment Advice Deliver

+ + +

Beta License: AGPL-3 OCA/wms Translate me on Weblate Try me on Runboat

+

This module adds an action to the release channel to automate the delivery of +its shippings through shipment advices.

+

Table of contents

+ +
+

Usage

+

A “Deliver” button for locked release channels is added.

+
+
When this new button is pressed:
+
    +
  • The release channel change its state to “delivering”.
  • +
  • +
    A background task (job queue) is planned to:
    +
      +
    • Validate the shippings related to the release channel.
    • +
    • Create the shipment advices.
    • +
    • Processes the shipment advices.
    • +
    +
    +
    +
  • +
+
+
At the end of the background task:
+
    +
  • The release channel status moves to “delivered” if no errors are detected.
  • +
  • Otherwise appropriate error messages are displayed and a button to retry +is shown to the user.
  • +
+
+
+
+
+

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

+
    +
  • ACSONE SA/NV
  • +
  • BCIM
  • +
+
+
+

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/wms project on GitHub.

+

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

+
+
+
+ + diff --git a/stock_release_channel_shipment_advice_deliver/tests/__init__.py b/stock_release_channel_shipment_advice_deliver/tests/__init__.py new file mode 100644 index 0000000000..f1d635b226 --- /dev/null +++ b/stock_release_channel_shipment_advice_deliver/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_stock_release_channel_deliver +from . import test_stock_release_channel_deliver_async diff --git a/stock_release_channel_shipment_advice_deliver/tests/common.py b/stock_release_channel_shipment_advice_deliver/tests/common.py new file mode 100644 index 0000000000..8bf2b57fe8 --- /dev/null +++ b/stock_release_channel_shipment_advice_deliver/tests/common.py @@ -0,0 +1,46 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from odoo.addons.stock_release_channel.tests.common import ChannelReleaseCase + + +class TestStockReleaseChannelDeliverCommon(ChannelReleaseCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.wh.write({"delivery_steps": "pick_ship"}) + cls.output_loc = cls.wh.wh_output_stock_loc_id + cls.channel.picking_ids.move_ids.write({"procure_method": "make_to_stock"}) + cls._update_qty_in_location(cls.wh.lot_stock_id, cls.product1, 100) + cls._update_qty_in_location(cls.wh.lot_stock_id, cls.product2, 100) + cls.channel.picking_ids.move_ids._compute_ordered_available_to_promise() + cls.channel.picking_ids.release_available_to_promise() + cls.dock = cls.env.ref("shipment_advice.stock_dock_demo") + cls.dock.warehouse_id = cls.wh + cls.warehouse2 = cls.env.ref("stock.stock_warehouse_shop0") + cls.channel.dock_id = cls.dock + cls.channel.auto_deliver = True + cls.channel.action_lock() + cls.channel.shipment_planning_method = "simple" + cls.internal_pickings = ( + cls.channel.picking_ids.move_ids.move_orig_ids.picking_id.filtered( + lambda p: p.picking_type_code == "internal" + ) + ) + cls.pickings = cls.channel.picking_ids.filtered( + lambda p: p.picking_type_code == "outgoing" + ) + + @classmethod + def _do_internal_pickings(cls): + for picking in cls.internal_pickings: + cls._do_picking(picking) + + @classmethod + def _do_picking(cls, picking): + if picking.state != "assigned": + return + for move in picking.move_ids: + move.quantity_done = move.product_qty + picking._action_done() diff --git a/stock_release_channel_shipment_advice_deliver/tests/test_stock_release_channel_deliver.py b/stock_release_channel_shipment_advice_deliver/tests/test_stock_release_channel_deliver.py new file mode 100644 index 0000000000..1bbc51c81c --- /dev/null +++ b/stock_release_channel_shipment_advice_deliver/tests/test_stock_release_channel_deliver.py @@ -0,0 +1,234 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields +from odoo.exceptions import UserError +from odoo.tools import mute_logger + +from odoo.addons.queue_job.tests.common import trap_jobs + +from .common import TestStockReleaseChannelDeliverCommon + + +class TestStockReleaseChannelDeliver(TestStockReleaseChannelDeliverCommon): + def test_action_deliver_allowed(self): + """Test action_deliver allowed.""" + self.channel.action_unlock() + self.assertEqual(self.channel.state, "open") + with self.assertRaises( + UserError, msg="Action 'Deliver' is not allowed for the channel Default." + ): + self.channel.action_deliver() + + def test_deliver_process(self): + """Shipment advices are created and automatically processed.""" + self._do_internal_pickings() + with trap_jobs() as trap_rc: + self.channel.action_deliver() + self.assertEqual(self.channel.state, "delivering") + trap_rc.assert_enqueued_job(self.channel._process_shipments) + with trap_jobs() as trap_sa: + trap_rc.perform_enqueued_jobs() + advices = self.channel.shipment_advice_ids.filtered( + lambda s: s.state not in ("done", "cancel") + ) + trap_sa.assert_enqueued_job(advices._auto_process) + with trap_jobs() as trap_ba: + # this job should create a job to assign backorders to a release channel + trap_sa.perform_enqueued_jobs() + trap_ba.perform_enqueued_jobs() + shipment_advice = advices.filtered(lambda s: s.state == "done") + self.assertTrue(shipment_advice) + self.assertEqual(shipment_advice.planned_pickings_count, 3) + self.assertEqual(shipment_advice.shipment_type, "outgoing") + self.assertEqual(shipment_advice.warehouse_id, self.wh) + self.assertEqual(shipment_advice.state, "done") + self.assertEqual(shipment_advice.planned_picking_ids, self.pickings) + self.assertEqual(shipment_advice.loaded_picking_ids, self.pickings) + self.assertTrue(shipment_advice.in_release_channel_auto_process) + self.assertSetEqual(set(self.pickings.mapped("state")), {"done"}) + self.assertEqual(self.channel.state, "delivered") + + @mute_logger( + "odoo.addons.stock_release_channel_shipment_advice_deliver.models.shipment_advice" + ) + def test_deliver_error_message(self): + """An error occurred while processing the shipment advices. + + The release channel is notified and the error is logged + """ + self._do_internal_pickings() + self.channel.dock_id = False + with trap_jobs() as trap_rc: + self.channel.action_deliver() + self.assertEqual(self.channel.state, "delivering") + trap_rc.assert_enqueued_job(self.channel._process_shipments) + with trap_jobs() as trap_sa: + trap_rc.perform_enqueued_jobs() + shipment_advice = self.channel.shipment_advice_ids + trap_sa.assert_enqueued_job(shipment_advice._auto_process) + trap_sa.perform_enqueued_jobs() + self.assertEqual(self.channel.state, "delivering_error") + self.assertEqual( + self.channel.delivering_error, + f"An error occurred while processing:\n" + f"- {shipment_advice.name}: Dock should be set on the shipment advice " + f"{shipment_advice.name}.", + ) + + def test_deliver_retry(self): + """Re-deliver after fail.""" + self.test_deliver_error_message() + self.assertEqual(self.channel.state, "delivering_error") + self.channel.dock_id = self.dock + self.test_deliver_process() + + def test_deliver_error_empty(self): + """No picking to deliver, an error should be raised.""" + self._do_internal_pickings() + self.pickings.write({"release_channel_id": False}) + with self.assertRaises( + UserError, msg="No picking to deliver for channel Default" + ): + self.channel.action_deliver() + + def test_deliver_backorder_not_reassigned(self): + """ + Deliver with backorder, no other channel available: + + - the backorder should not be assigned to the channel + - the backorder should not be assigned to the shipment advice + """ + self._update_qty_in_location(self.output_loc, self.product2, 10) + self.pickings.do_unreserve() + self.pickings.action_assign() + self.test_deliver_process() + backorder = self.pickings.backorder_ids + self.assertFalse(backorder.release_channel_id) + self.assertFalse(backorder.planned_shipment_advice_id) + + def test_deliver_backorder_reassigned(self): + """ + Deliver with backorder, another channel available: + + - the backorder should be assigned to the available channel + - the backorder should not be assigned to the shipment advice + """ + channel = self.channel.copy({"name": "channel 2", "state": "open"}) + self._do_internal_pickings() + self._update_qty_in_location(self.output_loc, self.product2, 10) + self.pickings.do_unreserve() + self.pickings.action_assign() + self.test_deliver_process() + backorder = self.pickings.backorder_ids + self.assertEqual(backorder.release_channel_id, channel) + self.assertFalse(backorder.planned_shipment_advice_id) + + def test_deliver_fails_picking_printed(self): + """Deliver is not allowed if one of the pickings is started.""" + self.internal_pickings[0].printed = True + with self.assertRaises( + UserError, + msg="One of the pickings to deliver for channel Default is started.", + ): + self.channel.action_deliver() + + def test_deliver_remaining_picking_unreleased(self): + """Deliver is allowed by a user confirmation. + + If one of the released picking is not done, the undone picking will be unreleased + """ + self._do_picking(self.internal_pickings[0]) + self._do_picking(self.internal_pickings[1]) + not_done_picking = self.internal_pickings.filtered( + lambda p: p.state == "assigned" + ) + res = self.channel.action_deliver() + self.assertIsInstance(res, dict) + wizard = ( + self.env[res.get("res_model")].with_context(**res.get("context")).create({}) + ) + with trap_jobs() as trap_rc: + wizard.action_deliver() + self.assertEqual(self.channel.state, "delivering") + trap_rc.assert_enqueued_job(self.channel._process_shipments) + with trap_jobs() as trap_sa: + trap_rc.perform_enqueued_jobs() + shipment_advice = self.channel.shipment_advice_ids + trap_sa.assert_enqueued_job(shipment_advice._auto_process) + trap_sa.perform_enqueued_jobs() + self.assertEqual(self.channel.state, "delivered") + self.assertEqual(not_done_picking.state, "cancel") + + def test_deliver_fails_picking_started(self): + """Picking started. + + Deliver is not allowed if one of the released picking is not done + and the unrelease is not possible.""" + self._do_picking(self.internal_pickings[0]) + self._do_picking(self.internal_pickings[1]) + not_done_picking = self.internal_pickings.filtered( + lambda p: p.state == "assigned" + ) + not_done_picking.printed = True + names = ", ".join(not_done_picking.mapped("name")) + channel_name = self.channel.name + message = ( + f"One of the delivery for channel {channel_name} is waiting on another transfer." + f" \nPlease finish it manually or cancel its start and done quantities to be able " + f"to deliver.\n{names}" + ) + with self.assertRaises(UserError) as exc: + self.channel.action_deliver() + self.assertEqual(message, exc.exception.args[0]) + + def test_deliver_partial_pick_without_bo(self): + """Partial picking without backorder creation. + + Deliver must be allowed""" + # Process 2 out of 5 + self.internal_pickings.picking_type_id.create_backorder = "never" + for move in self.internal_pickings[0].move_ids: + move.quantity_done = 2 + self.internal_pickings[0].button_validate() + res = self.channel.action_deliver() + self.assertIsInstance(res, dict) + wizard = ( + self.env[res.get("res_model")].with_context(**res.get("context")).create({}) + ) + wizard.with_context(test_queue_job_no_delay=True).action_deliver() + self.assertEqual(self.channel.state, "delivered") + + def test_delivering_from_shipment_advice(self): + self.assertEqual(self.channel.state, "locked") + self.pickings.write({"release_channel_id": self.channel.id}) + self._do_internal_pickings() + self.assertTrue(self.channel.is_action_deliver_allowed) + shipment_advice = self.env["shipment.advice"].create( + { + "shipment_type": "outgoing", + "release_channel_id": self.channel.id, + "dock_id": self.channel.dock_id.id, + "arrival_date": fields.Datetime.now(), + } + ) + shipment_advice.action_confirm() + shipment_advice.action_in_progress() + self.assertEqual(self.channel.state, "delivering") + + def test_deliver_partial_pick_with_bo(self): + """Partial picking with backorder creation. + + Deliver must be allowed""" + # Process 2 out of 5 + self.internal_pickings.picking_type_id.create_backorder = "always" + for move in self.internal_pickings[0].move_ids: + move.quantity_done = 2 + self.internal_pickings[0].button_validate() + res = self.channel.action_deliver() + self.assertIsInstance(res, dict) + wizard = ( + self.env[res.get("res_model")].with_context(**res.get("context")).create({}) + ) + wizard.with_context(test_queue_job_no_delay=True).action_deliver() + self.assertEqual(self.channel.state, "delivered") diff --git a/stock_release_channel_shipment_advice_deliver/tests/test_stock_release_channel_deliver_async.py b/stock_release_channel_shipment_advice_deliver/tests/test_stock_release_channel_deliver_async.py new file mode 100644 index 0000000000..0248cb45ab --- /dev/null +++ b/stock_release_channel_shipment_advice_deliver/tests/test_stock_release_channel_deliver_async.py @@ -0,0 +1,235 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.exceptions import UserError +from odoo.tools import mute_logger + +from odoo.addons.queue_job.tests.common import trap_jobs + +from .common import TestStockReleaseChannelDeliverCommon + + +class TestStockReleaseChannelDeliverAsync(TestStockReleaseChannelDeliverCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env.user.company_id.shipment_advice_run_in_queue_job = True + + def test_deliver_process(self): + """Shipment advices are created and automatically processed.""" + self._do_internal_pickings() + with trap_jobs() as trap_rc: + self.channel.action_deliver() + self.assertEqual(self.channel.state, "delivering") + trap_rc.assert_enqueued_job(self.channel._process_shipments) + with trap_jobs() as trap_sa: + trap_rc.perform_enqueued_jobs() + advices = self.channel.shipment_advice_ids.filtered( + lambda s: s.state not in ("done", "cancel") + ) + trap_sa.assert_enqueued_job(advices._auto_process) + with trap_jobs() as trap_sap: + # picking jobs + trap_sa.perform_enqueued_jobs() + trap_sap.assert_jobs_count(5) + # 3 picking + 1 for unplan + 1 for postprocess + with trap_jobs() as trap_ba: + # this job should create a job to assign backorders to a release channel + trap_sap.perform_enqueued_jobs() + trap_ba.perform_enqueued_jobs() + shipment_advice = advices.filtered(lambda s: s.state == "done") + self.assertTrue(shipment_advice) + self.assertEqual(shipment_advice.planned_pickings_count, 3) + self.assertEqual(shipment_advice.shipment_type, "outgoing") + self.assertEqual(shipment_advice.warehouse_id, self.wh) + self.assertEqual(shipment_advice.state, "done") + self.assertEqual(shipment_advice.planned_picking_ids, self.pickings) + self.assertEqual(shipment_advice.loaded_picking_ids, self.pickings) + self.assertTrue(shipment_advice.in_release_channel_auto_process) + self.assertSetEqual(set(self.pickings.mapped("state")), {"done"}) + self.assertEqual(self.channel.state, "delivered") + + @mute_logger( + "odoo.addons.stock_release_channel_shipment_advice_deliver.models.shipment_advice" + ) + def test_deliver_error_message(self): + """An error occurred while processing the shipment advices. + + The release channel is notified and the error is logged + """ + self._do_internal_pickings() + self.channel.dock_id = False + with trap_jobs() as trap_rc: + self.channel.action_deliver() + self.assertEqual(self.channel.state, "delivering") + trap_rc.assert_enqueued_job(self.channel._process_shipments) + with trap_jobs() as trap_sa: + trap_rc.perform_enqueued_jobs() + shipment_advice = self.channel.shipment_advice_ids + trap_sa.assert_enqueued_job(shipment_advice._auto_process) + trap_sa.perform_enqueued_jobs() + self.assertEqual(self.channel.state, "delivering_error") + self.assertEqual( + self.channel.delivering_error, + f"An error occurred while processing:\n" + f"- {shipment_advice.name}: Dock should be set on the shipment advice " + f"{shipment_advice.name}.", + ) + + def test_deliver_retry(self): + """Re-deliver after fail.""" + self.test_deliver_error_message() + self.assertEqual(self.channel.state, "delivering_error") + self.channel.dock_id = self.dock + self.test_deliver_process() + + def test_deliver_error_empty(self): + """No picking to deliver, an error should be raised.""" + self._do_internal_pickings() + self.pickings.write({"release_channel_id": False}) + with self.assertRaises( + UserError, msg="No picking to deliver for channel Default" + ): + self.channel.action_deliver() + + def test_deliver_backorder_not_reassigned(self): + """ + Deliver with backorder, no other channel available: + + - the backorder should not be assigned to the channel + - the backorder should not be assigned to the shipment advice + """ + self._update_qty_in_location(self.output_loc, self.product2, 10) + self.pickings.do_unreserve() + self.pickings.action_assign() + self.test_deliver_process() + backorder = self.pickings.backorder_ids + self.assertFalse(backorder.release_channel_id) + self.assertFalse(backorder.planned_shipment_advice_id) + + def test_deliver_backorder_reassigned(self): + """ + Deliver with backorder, another channel available: + + - the backorder should be assigned to the available channel + - the backorder should not be assigned to the shipment advice + """ + channel = self.channel.copy({"name": "channel 2", "state": "open"}) + self._do_internal_pickings() + self._update_qty_in_location(self.output_loc, self.product2, 10) + self.pickings.do_unreserve() + self.pickings.action_assign() + self.test_deliver_process() + backorder = self.pickings.backorder_ids + self.assertEqual(backorder.release_channel_id, channel) + self.assertFalse(backorder.planned_shipment_advice_id) + + def test_deliver_remaining_picking_unreleased(self): + """Deliver is allowed by a user confirmation. + + If one of the released picking is not done, the undone picking will be unreleased + """ + self._do_picking(self.internal_pickings[0]) + self._do_picking(self.internal_pickings[1]) + not_done_picking = self.internal_pickings.filtered( + lambda p: p.state == "assigned" + ) + res = self.channel.action_deliver() + self.assertIsInstance(res, dict) + wizard = ( + self.env[res.get("res_model")].with_context(**res.get("context")).create({}) + ) + with trap_jobs() as trap_rc: + wizard.action_deliver() + self.assertEqual(self.channel.state, "delivering") + trap_rc.assert_enqueued_job(self.channel._process_shipments) + with trap_jobs() as trap_sa: + trap_rc.perform_enqueued_jobs() + advices = self.channel.shipment_advice_ids.filtered( + lambda s: s.state not in ("done", "cancel") + ) + trap_sa.assert_enqueued_job(advices._auto_process) + with trap_jobs() as trap_sap: + # picking jobs + trap_sa.perform_enqueued_jobs() + trap_sap.assert_jobs_count(4) + # 2 picking + 1 for unplan + 1 for postprocess + with trap_jobs() as trap_ba: + # this job should create a job to assign backorders to a release channel + trap_sap.perform_enqueued_jobs() + trap_ba.perform_enqueued_jobs() + advices.filtered(lambda s: s.state == "done") + self.assertEqual(self.channel.state, "delivered") + self.assertEqual(not_done_picking.state, "cancel") + + def _process_deliver_jobs(self, expected_jobs_count): + with trap_jobs() as trap_rc: + self.channel.action_deliver() + self.assertEqual(self.channel.state, "delivering") + trap_rc.assert_enqueued_job(self.channel._process_shipments) + with trap_jobs() as trap_sa: + trap_rc.perform_enqueued_jobs() + advices = self.channel.shipment_advice_ids.filtered( + lambda s: s.state not in ("done", "cancel") + ) + trap_sa.assert_enqueued_job(advices._auto_process) + with trap_jobs() as trap_sap: + # picking jobs + trap_sa.perform_enqueued_jobs() + # number pickings + 1 for unplan + 1 for postprocess + trap_sap.assert_jobs_count(expected_jobs_count) + + with trap_jobs() as trap_ba: + # this job should create a job to assign backorders to a release channel + trap_sap.perform_enqueued_jobs() + trap_ba.perform_enqueued_jobs() + return advices + + def test_deliver_retry_after_partial_fail(self): + """Retry deliver from release channel after partial fail.""" + self._do_internal_pickings() + picking = self.channel.picking_to_plan_ids[0] + package = self.env["stock.quant.package"].create({}) + self.env["stock.quant"]._update_available_quantity( + self.product1, self.loc_stock, 2, package_id=package + ) + picking.move_line_ids.result_package_id = package + shipment_advice = self._process_deliver_jobs( + expected_jobs_count=5 + ) # 3 pickings to do + self.assertEqual(shipment_advice.state, "error") + self.assertEqual(self.channel.state, "delivering_error") + self.assertEqual(picking.state, "assigned") + picking.move_line_ids.result_package_id = False + self._process_deliver_jobs(expected_jobs_count=3) # 1 picking remaining + self.assertEqual(self.channel.state, "delivered") + self.assertEqual(picking.state, "done") + self.assertEqual(shipment_advice.state, "done") + + def test_deliver_retry_from_shipment_advice(self): + """Retry deliver from shipment advice after partial fail.""" + self._do_internal_pickings() + picking = self.channel.picking_to_plan_ids[0] + package = self.env["stock.quant.package"].create({}) + self.env["stock.quant"]._update_available_quantity( + self.product1, self.loc_stock, 2, package_id=package + ) + picking.move_line_ids.result_package_id = package + shipment_advice = self._process_deliver_jobs( + expected_jobs_count=5 + ) # 3 pickings to do + self.assertEqual(shipment_advice.state, "error") + self.assertEqual(self.channel.state, "delivering_error") + self.assertEqual(picking.state, "assigned") + picking.move_line_ids.result_package_id = False + with trap_jobs() as trap_sap: + shipment_advice.action_done() + self.assertEqual(self.channel.state, "delivering") + self.assertEqual(shipment_advice.state, "in_process") + # picking jobs + # number pickings + 1 for unplan + 1 for postprocess + trap_sap.assert_jobs_count(3) + trap_sap.perform_enqueued_jobs() + self.assertEqual(self.channel.state, "delivered") + self.assertEqual(picking.state, "done") + self.assertEqual(shipment_advice.state, "done") diff --git a/stock_release_channel_shipment_advice_deliver/views/stock_release_channel.xml b/stock_release_channel_shipment_advice_deliver/views/stock_release_channel.xml new file mode 100644 index 0000000000..937fb4bb3b --- /dev/null +++ b/stock_release_channel_shipment_advice_deliver/views/stock_release_channel.xml @@ -0,0 +1,113 @@ + + + + + + stock.release.channel + + + + + + + + + stock.release.channel + + + + + + + + + + {'search_default_filter_open': True, 'search_default_filter_locked': True, 'search_default_filter_delivering': True} + + + + stock.release.channel + + + + + +
+ Deliver +
+
+
+
+
+ + + stock.release.channel + + + + + + + record.can_plan_shipment.raw_value and !record.auto_deliver.raw_value + + + + +
diff --git a/stock_release_channel_shipment_advice_deliver/wizards/__init__.py b/stock_release_channel_shipment_advice_deliver/wizards/__init__.py new file mode 100644 index 0000000000..6820d4d583 --- /dev/null +++ b/stock_release_channel_shipment_advice_deliver/wizards/__init__.py @@ -0,0 +1 @@ +from . import stock_release_channel_deliver_check_wizard diff --git a/stock_release_channel_shipment_advice_deliver/wizards/stock_release_channel_deliver_check_wizard.py b/stock_release_channel_shipment_advice_deliver/wizards/stock_release_channel_deliver_check_wizard.py new file mode 100644 index 0000000000..6879e718bb --- /dev/null +++ b/stock_release_channel_shipment_advice_deliver/wizards/stock_release_channel_deliver_check_wizard.py @@ -0,0 +1,18 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class StockReleaseChannelDeliverCheckWizard(models.TransientModel): + + _name = "stock.release.channel.deliver.check.wizard" + _description = "stock release channel deliver check wizard" + + release_channel_id = fields.Many2one("stock.release.channel") + + def action_deliver(self): + self.ensure_one() + self.release_channel_id.unrelease_picking() + self.release_channel_id._action_deliver() + return {"type": "ir.actions.act_window_close"} diff --git a/stock_release_channel_shipment_advice_deliver/wizards/stock_release_channel_deliver_check_wizard.xml b/stock_release_channel_shipment_advice_deliver/wizards/stock_release_channel_deliver_check_wizard.xml new file mode 100644 index 0000000000..899a069617 --- /dev/null +++ b/stock_release_channel_shipment_advice_deliver/wizards/stock_release_channel_deliver_check_wizard.xml @@ -0,0 +1,39 @@ + + + + + + stock.release.channel.deliver.check.wizard + +
+ +

+ There are some preparations that have not been completed. + If you choose to proceed, these preparations will be unreleased.
+ Are you sure you want to proceed with the delivery? +

+ + +
+
+ + + +