diff --git a/setup/stock_release_channel/odoo/addons/stock_release_channel b/setup/stock_release_channel/odoo/addons/stock_release_channel new file mode 120000 index 0000000000..affde1dccc --- /dev/null +++ b/setup/stock_release_channel/odoo/addons/stock_release_channel @@ -0,0 +1 @@ +../../../../stock_release_channel \ No newline at end of file diff --git a/setup/stock_release_channel/setup.py b/setup/stock_release_channel/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/stock_release_channel/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/stock_release_channel/README.rst b/stock_release_channel/README.rst new file mode 100644 index 0000000000..84b6c6a511 --- /dev/null +++ b/stock_release_channel/README.rst @@ -0,0 +1,130 @@ +====================== +Stock Release Channels +====================== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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 + :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 + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/285/16.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Release channels are: + +* Release channels are created by stock managers (only pallets, only parcels, ...) +* A release channel has a sequence, a domain + possibility to use python code +* When a delivery is: created from a sales order / created as backorder / + released, each release channel is evaluated against it (domain + python code), + the delivery is assigned to the first channel that matches +* A release channel can change over time: for instance the evaluation of a + domain or rule can change if a delivery is only partially released +* A kanban board allows tracking how many [To Do Today, Released, Done Today, + Waiting, Late, Priority] Transfers are in each channel, plus quick access to + all the pick/pack transfers for released deliveries +* A button on each channel allows to release the next X (configured on the + channel) transfers (max X at a time, it releases X - currently released and + not done) + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +In Inventory > Configuration > Release Channels. +Only Stock Managers have write permissions. + +Usage +===== + +Use Inventory > Operations > Release Channels to access to the dashboard. + +Each channel has a dashboard with statistics about the number of transfers +to release and of the progress of the released transfers. + +When clicking on the "box" button, transfers are released automatically, to +reach a total of (option configured in the channel +settings). + +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 smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Camptocamp + +Contributors +~~~~~~~~~~~~ + +* Guewen Baconnier +* Matthieu Méquignon +* Sébastien Alix +* Jacques-Etienne Baudoux + +Design +~~~~~~ + +* Joël Grand-Guillaume +* Jacques-Etienne Baudoux + +Other credits +~~~~~~~~~~~~~ + +**Financial support** + +* Cosanum +* Camptocamp R&D + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-sebalix| image:: https://github.com/sebalix.png?size=40px + :target: https://github.com/sebalix + :alt: sebalix + +Current `maintainer `__: + +|maintainer-sebalix| + +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/__init__.py b/stock_release_channel/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/stock_release_channel/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/stock_release_channel/__manifest__.py b/stock_release_channel/__manifest__.py new file mode 100644 index 0000000000..4e91dfebba --- /dev/null +++ b/stock_release_channel/__manifest__.py @@ -0,0 +1,35 @@ +# Copyright 2020 Camptocamp +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +{ + "name": "Stock Release Channels", + "summary": "Manage workload in WMS with release channels", + "version": "16.0.1.0.0", + "development_status": "Beta", + "license": "AGPL-3", + "author": "Camptocamp, Odoo Community Association (OCA)", + "maintainers": ["sebalix"], + "website": "https://github.com/OCA/wms", + "depends": [ + "web", + "sale_stock", + "stock_available_to_promise_release", # OCA/wms + "queue_job", # OCA/queue + ], + "data": [ + # "views/assets.xml", + "views/stock_release_channel_views.xml", + "views/stock_picking_views.xml", + "data/stock_release_channel_data.xml", + "data/queue_job_data.xml", + "data/ir_cron_data.xml", + "security/stock_release_channel.xml", + ], + "assets": { + "web.assets_backend": [ + "stock_release_channel/static/src/scss/stock_release_channel.scss", + "stock_release_channel/static/src/js/progressbar_fractional_widget.js", + ], + }, + "installable": True, +} diff --git a/stock_release_channel/data/ir_cron_data.xml b/stock_release_channel/data/ir_cron_data.xml new file mode 100644 index 0000000000..3a01babcef --- /dev/null +++ b/stock_release_channel/data/ir_cron_data.xml @@ -0,0 +1,16 @@ + + + + + + Re-assign release channel + 1 + days + -1 + False + + code + model.assign_release_channel_on_all_need_release() + + + diff --git a/stock_release_channel/data/queue_job_data.xml b/stock_release_channel/data/queue_job_data.xml new file mode 100644 index 0000000000..33d79d8972 --- /dev/null +++ b/stock_release_channel/data/queue_job_data.xml @@ -0,0 +1,18 @@ + + + + + stock_release_channel + + + + + + assign_release_channel + + + + diff --git a/stock_release_channel/data/stock_release_channel_data.xml b/stock_release_channel/data/stock_release_channel_data.xml new file mode 100644 index 0000000000..d062822cba --- /dev/null +++ b/stock_release_channel/data/stock_release_channel_data.xml @@ -0,0 +1,10 @@ + + + + + Default + 999 + [] + + + diff --git a/stock_release_channel/i18n/stock_release_channel.pot b/stock_release_channel/i18n/stock_release_channel.pot new file mode 100644 index 0000000000..4a9a91a3f5 --- /dev/null +++ b/stock_release_channel/i18n/stock_release_channel.pot @@ -0,0 +1,539 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * stock_release_channel +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: stock_release_channel +#: model_terms:ir.ui.view,arch_db:stock_release_channel.stock_release_channel_kanban_view +msgid "" +"" +msgstr "" + +#. module: stock_release_channel +#: model_terms:ir.ui.view,arch_db:stock_release_channel.stock_release_channel_kanban_view +msgid "" +msgstr "" + +#. module: stock_release_channel +#: model_terms:ir.ui.view,arch_db:stock_release_channel.stock_release_channel_kanban_view +msgid "To Do Situation" +msgstr "" + +#. module: stock_release_channel +#: model_terms:ir.ui.view,arch_db:stock_release_channel.stock_release_channel_kanban_view +msgid "Transfers" +msgstr "" + +#. module: stock_release_channel +#: model_terms:ir.ui.view,arch_db:stock_release_channel.stock_release_channel_kanban_view +msgid "" +"Last Transfer\n" +"
" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__active +msgid "Active" +msgstr "" + +#. module: stock_release_channel +#: model_terms:ir.actions.act_window,help:stock_release_channel.stock_release_channel_config_act_window +msgid "Add a Release Channel" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__count_picking_chain_done +msgid "All Done Related Transfers" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__count_move_all +msgid "All Moves (Estimate)" +msgstr "" + +#. module: stock_release_channel +#: code:addons/stock_release_channel/models/stock_release_channel.py:0 +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__count_picking_chain +#, python-format +msgid "All Related Transfers" +msgstr "" + +#. module: stock_release_channel +#: model_terms:ir.ui.view,arch_db:stock_release_channel.stock_release_channel_tree_view +msgid "All Total" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__count_picking_all +msgid "All Transfers" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,help:stock_release_channel.field_stock_release_channel__picking_chain_ids +msgid "All transfers required to bring goods to the deliveries." +msgstr "" + +#. module: stock_release_channel +#: model_terms:ir.ui.view,arch_db:stock_release_channel.stock_release_channel_form_view +#: model_terms:ir.ui.view,arch_db:stock_release_channel.stock_release_channel_search_view +msgid "Archived" +msgstr "" + +#. module: stock_release_channel +#: code:addons/stock_release_channel/models/stock_picking.py:0 +#, python-format +msgid "Assign release channel on %s" +msgstr "" + +#. module: stock_release_channel +#: model_terms:ir.ui.view,arch_db:stock_release_channel.stock_release_channel_tree_view +msgid "Assigned Total" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__auto_release +msgid "Auto Release" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__count_move_assigned +msgid "Available Moves (Estimate)" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__count_picking_assigned +msgid "Available Transfers" +msgstr "" + +#. module: stock_release_channel +#: code:addons/stock_release_channel/models/stock_release_channel.py:0 +#, python-format +msgid "Channel %s has no validated transfer yet." +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__color +msgid "Color" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_picking__commercial_partner_id +msgid "Commercial Entity" +msgstr "" + +#. module: stock_release_channel +#: model_terms:ir.ui.view,arch_db:stock_release_channel.view_picking_internal_search +msgid "Commercial Partner" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__create_uid +msgid "Created by" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__create_date +msgid "Created on" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_sale_order_line__display_name +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_move__display_name +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_picking__display_name +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__display_name +msgid "Display Name" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__rule_domain +msgid "Domain" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,help:stock_release_channel.field_stock_release_channel__rule_domain +msgid "Domain based on Transfers, filter for entering the channel." +msgstr "" + +#. module: stock_release_channel +#: model_terms:ir.ui.view,arch_db:stock_release_channel.stock_release_channel_kanban_view +msgid "Done Today" +msgstr "" + +#. module: stock_release_channel +#: model_terms:ir.ui.view,arch_db:stock_release_channel.stock_release_channel_tree_view +msgid "Done Today Total" +msgstr "" + +#. module: stock_release_channel +#: model_terms:ir.ui.view,arch_db:stock_release_channel.stock_release_channel_form_view +msgid "Enter Python code here." +msgstr "" + +#. module: stock_release_channel +#: code:addons/stock_release_channel/models/stock_release_channel.py:0 +#, python-format +msgid "" +"Error when evaluating the channel's code:\n" +" %s \n" +"(%s)" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__release_forbidden +msgid "Forbid to release this channel" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__count_picking_full_progress +msgid "Full Progress" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields.selection,name:stock_release_channel.selection__stock_release_channel__auto_release__group_commercial_partner +msgid "Grouped by Commercial Partner" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_sale_order_line__id +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_move__id +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_picking__id +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__id +msgid "ID" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__count_picking_chain_in_progress +msgid "In progress Related Transfers" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__last_done_picking_date_done +msgid "Last Done Picking Date Done" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__last_done_picking_name +msgid "Last Done Picking Name" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__last_done_picking_id +msgid "Last Done Transfer" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_sale_order_line____last_update +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_move____last_update +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_picking____last_update +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel____last_update +msgid "Last Modified on" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__write_date +msgid "Last Updated on" +msgstr "" + +#. module: stock_release_channel +#: model_terms:ir.ui.view,arch_db:stock_release_channel.stock_release_channel_kanban_view +msgid "Late" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__count_move_late +msgid "Late Moves (Estimate)" +msgstr "" + +#. module: stock_release_channel +#: model_terms:ir.ui.view,arch_db:stock_release_channel.stock_release_channel_tree_view +msgid "Late Total" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__count_picking_late +msgid "Late Transfers" +msgstr "" + +#. module: stock_release_channel +#: model_terms:ir.ui.view,arch_db:stock_release_channel.stock_release_channel_kanban_view +msgid "Lines]" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields.selection,name:stock_release_channel.selection__stock_release_channel__auto_release__max +msgid "Max" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__max_auto_release +msgid "Max Transfers to release" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,help:stock_release_channel.field_stock_release_channel__auto_release +msgid "" +"Max: release N transfers to have a configured max of X deliveries in progress.\n" +"Grouped by Commercial Partner: release all transfers for acommercial partner at once." +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__count_move_done +msgid "Moves Done Today (Estimate)" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__name +msgid "Name" +msgstr "" + +#. module: stock_release_channel +#: code:addons/stock_release_channel/models/stock_release_channel.py:0 +#, python-format +msgid "No Max transfers to release is configured." +msgstr "" + +#. module: stock_release_channel +#: model_terms:ir.ui.view,arch_db:stock_release_channel.view_picking_internal_search +msgid "No Release Channel" +msgstr "" + +#. module: stock_release_channel +#: model_terms:ir.actions.act_window,help:stock_release_channel.stock_release_channel_act_window +msgid "No Release Channel configured" +msgstr "" + +#. module: stock_release_channel +#: code:addons/stock_release_channel/models/stock_release_channel.py:0 +#: code:addons/stock_release_channel/tests/test_channel_release_batch.py:0 +#, python-format +msgid "Nothing in the queue!" +msgstr "" + +#. module: stock_release_channel +#: model_terms:ir.ui.view,arch_db:stock_release_channel.stock_release_channel_kanban_view +msgid "Overall Progress" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__picking_chain_ids +msgid "Picking Chain" +msgstr "" + +#. module: stock_release_channel +#: model_terms:ir.ui.view,arch_db:stock_release_channel.stock_release_channel_kanban_view +msgid "Priority" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__count_move_priority +msgid "Priority Moves (Estimate)" +msgstr "" + +#. module: stock_release_channel +#: model_terms:ir.ui.view,arch_db:stock_release_channel.stock_release_channel_tree_view +msgid "Priority Total" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__count_picking_priority +msgid "Priority Transfers" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__code +msgid "Python Code" +msgstr "" + +#. module: stock_release_channel +#: model:ir.actions.server,name:stock_release_channel.ir_cron_stock_picking_assign_release_channel_ir_actions_server +#: model:ir.cron,cron_name:stock_release_channel.ir_cron_stock_picking_assign_release_channel +#: model:ir.cron,name:stock_release_channel.ir_cron_stock_picking_assign_release_channel +msgid "Re-assign release channel" +msgstr "" + +#. module: stock_release_channel +#: model_terms:ir.ui.view,arch_db:stock_release_channel.stock_release_channel_tree_view +msgid "Related Done Total" +msgstr "" + +#. module: stock_release_channel +#: model_terms:ir.ui.view,arch_db:stock_release_channel.stock_release_channel_tree_view +msgid "Related In Progress Total" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_picking__release_channel_id +#: model_terms:ir.ui.view,arch_db:stock_release_channel.view_picking_internal_search +msgid "Release Channel" +msgstr "" + +#. module: stock_release_channel +#: model:ir.actions.act_window,name:stock_release_channel.stock_release_channel_act_window +#: model:ir.actions.act_window,name:stock_release_channel.stock_release_channel_config_act_window +#: model:ir.ui.menu,name:stock_release_channel.stock_release_channel_config_menu +#: model:ir.ui.menu,name:stock_release_channel.stock_release_channel_menu +msgid "Release Channels" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__count_move_release_ready +msgid "Release Ready Moves (Estimate)" +msgstr "" + +#. module: stock_release_channel +#: model_terms:ir.ui.view,arch_db:stock_release_channel.stock_release_channel_tree_view +msgid "Release Ready Total" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__count_picking_release_ready +msgid "Release Ready Transfers" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__count_move_released +msgid "Released Moves (Estimate)" +msgstr "" + +#. module: stock_release_channel +#: model_terms:ir.ui.view,arch_db:stock_release_channel.stock_release_channel_tree_view +msgid "Released Total" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__count_picking_released +msgid "Released Transfers" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model,name:stock_release_channel.model_sale_order_line +msgid "Sales Order Line" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__sequence +msgid "Sequence" +msgstr "" + +#. module: stock_release_channel +#: model_terms:ir.ui.view,arch_db:stock_release_channel.stock_release_channel_kanban_view +msgid "Settings" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model,name:stock_release_channel.model_stock_move +msgid "Stock Move" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model,name:stock_release_channel.model_stock_release_channel +msgid "Stock Release Channels" +msgstr "" + +#. module: stock_release_channel +#: code:addons/stock_release_channel/models/stock_release_channel.py:0 +#, python-format +msgid "" +"The number of released transfers in progress is already at the maximum." +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,help:stock_release_channel.field_stock_release_channel__count_picking_full_progress +msgid "The total number of pickings to achieve 100% of progress." +msgstr "" + +#. module: stock_release_channel +#: model_terms:ir.ui.view,arch_db:stock_release_channel.stock_release_channel_kanban_view +msgid "To Do" +msgstr "" + +#. module: stock_release_channel +#: model_terms:ir.ui.view,arch_db:stock_release_channel.stock_release_channel_kanban_view +msgid "To Release" +msgstr "" + +#. module: stock_release_channel +#: model_terms:ir.ui.view,arch_db:stock_release_channel.stock_release_channel_kanban_view +msgid "To Ship" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model,name:stock_release_channel.model_stock_picking +msgid "Transfer" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__picking_ids +msgid "Transfers" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__count_picking_done +msgid "Transfers Done Today" +msgstr "" + +#. module: stock_release_channel +#: model_terms:ir.ui.view,arch_db:stock_release_channel.stock_release_channel_kanban_view +msgid "Transfers Progress" +msgstr "" + +#. module: stock_release_channel +#: model_terms:ir.ui.view,arch_db:stock_release_channel.view_picking_internal_search +msgid "Transfers that couldn't be assigned in a release channel" +msgstr "" + +#. module: stock_release_channel +#: model_terms:ir.ui.view,arch_db:stock_release_channel.stock_release_channel_kanban_view +msgid "Waiting" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__count_move_waiting +msgid "Waiting Moves (Estimate)" +msgstr "" + +#. module: stock_release_channel +#: model_terms:ir.ui.view,arch_db:stock_release_channel.stock_release_channel_tree_view +msgid "Waiting Total" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__count_picking_waiting +msgid "Waiting Transfers" +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,help:stock_release_channel.field_stock_release_channel__max_auto_release +msgid "" +"When clicking on the package icon, it releases X transfers minus on-going " +"ones not shipped (X - Waiting). This field defines X." +msgstr "" + +#. module: stock_release_channel +#: model:ir.model.fields,help:stock_release_channel.field_stock_release_channel__code +msgid "Write Python code to filter out pickings." +msgstr "" + +#. module: stock_release_channel +#: code:addons/stock_release_channel/models/stock_picking.py:0 +#, python-format +msgid "" +"You cannot release delivery of the channel %s because it has been forbidden " +"in the release channel configuration" +msgstr "" diff --git a/stock_release_channel/models/__init__.py b/stock_release_channel/models/__init__.py new file mode 100644 index 0000000000..66e7660731 --- /dev/null +++ b/stock_release_channel/models/__init__.py @@ -0,0 +1,3 @@ +from . import stock_move +from . import stock_picking +from . import stock_release_channel diff --git a/stock_release_channel/models/stock_move.py b/stock_release_channel/models/stock_move.py new file mode 100644 index 0000000000..e653533211 --- /dev/null +++ b/stock_release_channel/models/stock_move.py @@ -0,0 +1,24 @@ +# Copyright 2020 Camptocamp +# Copyright 2022 Jacques-Etienne Baudoux (BCIM) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import models + + +class StockMove(models.Model): + _inherit = "stock.move" + + def release_available_to_promise(self): + # after releasing, we re-assign a release channel, + # as we may release only partially, the channel may + # change + res = super().release_available_to_promise() + self.picking_id.assign_release_channel() + return res + + def _action_confirm(self, merge=True, merge_into=False): + moves = super()._action_confirm(merge=merge, merge_into=merge_into) + pickings = moves.filtered("need_release").picking_id + if pickings: + pickings._delay_assign_release_channel() + return moves diff --git a/stock_release_channel/models/stock_picking.py b/stock_release_channel/models/stock_picking.py new file mode 100644 index 0000000000..02d8a9bb6d --- /dev/null +++ b/stock_release_channel/models/stock_picking.py @@ -0,0 +1,55 @@ +# Copyright 2020 Camptocamp +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import _, exceptions, fields, models + +from odoo.addons.queue_job.job import identity_exact + + +class StockPicking(models.Model): + _inherit = "stock.picking" + + release_channel_id = fields.Many2one( + comodel_name="stock.release.channel", index=True, ondelete="restrict" + ) + commercial_partner_id = fields.Many2one( + comodel_name="res.partner", + string="Commercial Entity", + related="partner_id.commercial_partner_id", + store=True, + readonly=True, + ) + + def _delay_assign_release_channel(self): + for picking in self: + picking.with_delay( + identity_key=identity_exact, + description=_("Assign release channel on %s") % picking.name, + ).assign_release_channel() + + def assign_release_channel(self): + self.env["stock.release.channel"].assign_release_channel(self) + + def release_available_to_promise(self): + for record in self: + channel = record.release_channel_id + if channel.release_forbidden: + raise exceptions.UserError( + _( + "You cannot release delivery of the channel %s because " + "it has been forbidden in the release channel configuration" + ) + % channel.name + ) + return super().release_available_to_promise() + + def _create_backorder(self): + backorders = super()._create_backorder() + backorders._delay_assign_release_channel() + return backorders + + def assign_release_channel_on_all_need_release(self): + need_release = self.env["stock.picking"].search( + [("need_release", "=", True)], + ) + need_release._delay_assign_release_channel() diff --git a/stock_release_channel/models/stock_release_channel.py b/stock_release_channel/models/stock_release_channel.py new file mode 100644 index 0000000000..e6059d8543 --- /dev/null +++ b/stock_release_channel/models/stock_release_channel.py @@ -0,0 +1,633 @@ +# Copyright 2020 Camptocamp +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +import logging + +from pytz import timezone + +from odoo import _, api, exceptions, fields, models +from odoo.tools.safe_eval import ( + datetime as safe_datetime, + dateutil as safe_dateutil, + safe_eval, + test_python_expr, + time as safe_time, +) + +_logger = logging.getLogger(__name__) + + +class StockReleaseChannel(models.Model): + _name = "stock.release.channel" + _description = "Stock Release Channels" + _order = "sequence, id" + + DEFAULT_PYTHON_CODE = ( + "# Available variables:\n" + "# - env: Odoo Environment on which the action is triggered\n" + "# - records: pickings to filter\n" + "# - time, datetime, dateutil, timezone: useful Python libraries\n" + "#\n" + "# By default, all pickings are kept.\n" + "# To filter a selection of pickings, assign: pickings = ...\n\n\n\n\n" + ) + + name = fields.Char(required=True) + release_forbidden = fields.Boolean(string="Forbid to release this channel") + sequence = fields.Integer(default=lambda self: self._default_sequence()) + color = fields.Integer() + warehouse_id = fields.Many2one( + "stock.warehouse", + string="Warehouse", + index=True, + help="Warehouse for which this channel is relevant", + ) + picking_type_ids = fields.Many2many( + "stock.picking.type", + "stock_release_channel_warehouse_rel", + "channel_id", + "picking_type_id", + string="Operation Types", + domain="warehouse_id" + " and [('warehouse_id', '=', warehouse_id), ('code', '=', 'outgoing')]" + " or [('code', '=', 'outgoing')]", + ) + rule_domain = fields.Char( + string="Domain", + default=[], + help="Domain based on Transfers, filter for entering the channel.", + ) + code = fields.Text( + string="Python Code", + groups="base.group_system", + default=DEFAULT_PYTHON_CODE, + help="Write Python code to filter out pickings.", + ) + active = fields.Boolean(default=True) + + auto_release = fields.Selection( + selection=[ + ("max", "Max"), + ("group_commercial_partner", "Grouped by Commercial Partner"), + ], + default="max", + required=True, + help="Max: release N transfers to have a configured max of X deliveries" + " in progress.\nGrouped by Commercial Partner: release all transfers for a" + "commercial partner at once.", + ) + max_auto_release = fields.Integer( + string="Max Transfers to release", + default=10, + help="When clicking on the package icon, it releases X transfers minus " + " on-going ones not shipped (X - Waiting)." + " This field defines X.", + ) + + picking_ids = fields.One2many( + string="Transfers", + comodel_name="stock.picking", + inverse_name="release_channel_id", + ) + + # beware not to store any value which can be changed by concurrent + # stock.picking (e.g. the state cannot be stored) + + count_picking_all = fields.Integer( + string="All Transfers", compute="_compute_picking_count" + ) + count_picking_release_ready = fields.Integer( + string="Release Ready Transfers", compute="_compute_picking_count" + ) + count_picking_released = fields.Integer( + string="Released Transfers", compute="_compute_picking_count" + ) + count_picking_assigned = fields.Integer( + string="Available Transfers", compute="_compute_picking_count" + ) + count_picking_waiting = fields.Integer( + string="Waiting Transfers", compute="_compute_picking_count" + ) + count_picking_late = fields.Integer( + string="Late Transfers", compute="_compute_picking_count" + ) + count_picking_priority = fields.Integer( + string="Priority Transfers", compute="_compute_picking_count" + ) + count_picking_done = fields.Integer( + string="Transfers Done Today", compute="_compute_picking_count" + ) + count_picking_full_progress = fields.Integer( + string="Full Progress", + compute="_compute_picking_count", + help="The total number of pickings to achieve 100% of progress.", + ) + + picking_chain_ids = fields.Many2many( + comodel_name="stock.picking", + compute="_compute_picking_chain", + help="All transfers required to bring goods to the deliveries.", + ) + count_picking_chain = fields.Integer( + string="All Related Transfers", compute="_compute_picking_chain" + ) + count_picking_chain_in_progress = fields.Integer( + string="In progress Related Transfers", compute="_compute_picking_chain" + ) + count_picking_chain_done = fields.Integer( + string="All Done Related Transfers", compute="_compute_picking_chain" + ) + + count_move_all = fields.Integer( + string="All Moves (Estimate)", compute="_compute_picking_count" + ) + count_move_release_ready = fields.Integer( + string="Release Ready Moves (Estimate)", compute="_compute_picking_count" + ) + count_move_released = fields.Integer( + string="Released Moves (Estimate)", compute="_compute_picking_count" + ) + count_move_assigned = fields.Integer( + string="Available Moves (Estimate)", compute="_compute_picking_count" + ) + count_move_waiting = fields.Integer( + string="Waiting Moves (Estimate)", compute="_compute_picking_count" + ) + count_move_late = fields.Integer( + string="Late Moves (Estimate)", compute="_compute_picking_count" + ) + count_move_priority = fields.Integer( + string="Priority Moves (Estimate)", compute="_compute_picking_count" + ) + count_move_done = fields.Integer( + string="Moves Done Today (Estimate)", compute="_compute_picking_count" + ) + + last_done_picking_id = fields.Many2one( + string="Last Done Transfer", + comodel_name="stock.picking", + compute="_compute_last_done_picking", + ) + last_done_picking_name = fields.Char(compute="_compute_last_done_picking") + last_done_picking_date_done = fields.Datetime(compute="_compute_last_done_picking") + + def _field_picking_domains(self): + return { + "count_picking_all": [], + "count_picking_release_ready": [ + ("release_ready", "=", True), + # FIXME not TZ friendly + ( + "scheduled_date", + "<", + fields.Datetime.now().replace(hour=23, minute=59), + ), + ], + "count_picking_released": [ + ("need_release", "=", False), + ("state", "in", ("assigned", "waiting", "confirmed")), + ], + "count_picking_assigned": [ + ("need_release", "=", False), + ("state", "=", "assigned"), + ], + "count_picking_waiting": [ + ("need_release", "=", False), + ("state", "in", ("waiting", "confirmed")), + ], + "count_picking_late": [ + ("need_release", "=", False), + ("scheduled_date", "<", fields.Datetime.now()), + ("state", "in", ("assigned", "waiting", "confirmed")), + ], + "count_picking_priority": [ + ("need_release", "=", False), + ("priority", "=", "1"), + ("state", "in", ("assigned", "waiting", "confirmed")), + ], + "count_picking_done": [ + ("state", "=", "done"), + ("date_done", ">", fields.Datetime.now().replace(hour=0, minute=0)), + ], + } + + # TODO maybe we have to do raw SQL to include the picking + moves counts in + # a single query + def _compute_picking_count(self): + domains = self._field_picking_domains() + picking_ids_per_field = {} + for field, domain in domains.items(): + data = self.env["stock.picking"].read_group( + domain + [("release_channel_id", "in", self.ids)], + ["release_channel_id", "picking_ids:array_agg(id)"], + ["release_channel_id"], + ) + count = { + row["release_channel_id"][0]: row["release_channel_id_count"] + for row in data + if row["release_channel_id"] + } + picking_ids_per_field.update( + { + (row["release_channel_id"][0], field): row["picking_ids"] + for row in data + if row["release_channel_id"] + } + ) + + for record in self: + record[field] = count.get(record.id, 0) + + all_picking_ids = [ + pid for picking_ids in picking_ids_per_field.values() for pid in picking_ids + ] + data = self.env["stock.move"].read_group( + # TODO for now we do estimates, later we may improve the domains per + # field, but now we can run one sql query on stock.move for all fields + [("picking_id", "in", all_picking_ids), ("state", "!=", "cancel")], + ["picking_id"], + ["picking_id"], + ) + move_count = { + row["picking_id"][0]: row["picking_id_count"] + for row in data + if row["picking_id"] + } + for field, __ in domains.items(): + move_field = field.replace("picking", "move") + for record in self: + picking_ids = picking_ids_per_field.get((record.id, field), []) + move_estimate = sum( + move_count.get(picking_id, 0) for picking_id in picking_ids + ) + record[move_field] = move_estimate + + for record in self: + record.count_picking_full_progress = ( + record.count_picking_release_ready + + record.count_picking_released + + record.count_picking_done + ) + + def _query_get_chain(self, pickings): + """Get all stock.picking before an outgoing one + + Follow recursively the move_orig_ids. + Outgoing picking ids are excluded + """ + query = """ + WITH RECURSIVE + pickings AS ( + SELECT move.picking_id, + true as outgoing, + ''::varchar as state, -- no need it, we exclude outgoing + move.id as move_orig_id + FROM stock_move move + WHERE move.picking_id in %s + + UNION + + SELECT move.picking_id, + false as outgoing, + picking.state, + rel.move_orig_id + FROM stock_move_move_rel rel + INNER JOIN pickings + ON pickings.move_orig_id = rel.move_dest_id + INNER JOIN stock_move move + ON move.id = rel.move_orig_id + INNER JOIN stock_picking picking + ON picking.id = move.picking_id + ) + SELECT DISTINCT picking_id, state FROM pickings + WHERE outgoing is false; + """ + return (query, (tuple(pickings.ids),)) + + def _compute_picking_chain(self): + self.env["stock.move"].flush_model( + ["move_dest_ids", "move_orig_ids", "picking_id"] + ) + self.env["stock.picking"].flush_model(["state"]) + for channel in self: + domain = self._field_picking_domains()["count_picking_released"] + domain += [("release_channel_id", "=", channel.id)] + released = self.env["stock.picking"].search(domain) + + if not released: + channel.picking_chain_ids = False + channel.count_picking_chain = 0 + channel.count_picking_chain_in_progress = 0 + channel.count_picking_chain_done = 0 + continue + + self.env.cr.execute(*self._query_get_chain(released)) + rows = self.env.cr.dictfetchall() + channel.picking_chain_ids = [row["picking_id"] for row in rows] + channel.count_picking_chain_in_progress = sum( + [1 for row in rows if row["state"] not in ("cancel", "done")] + ) + channel.count_picking_chain_done = sum( + [1 for row in rows if row["state"] == "done"] + ) + channel.count_picking_chain = ( + channel.count_picking_chain_done + + channel.count_picking_chain_in_progress + ) + + def _compute_last_done_picking(self): + for channel in self: + # TODO we have one query per channel, could be better + domain = self._field_picking_domains()["count_picking_done"] + domain += [("release_channel_id", "=", channel.id)] + picking = self.env["stock.picking"].search( + domain, limit=1, order="date_done DESC" + ) + channel.last_done_picking_id = picking + channel.last_done_picking_name = picking.name + channel.last_done_picking_date_done = picking.date_done + + def _default_sequence(self): + default_channel = self.env.ref( + "stock_release_channel.stock_release_channel_default", + raise_if_not_found=False, + ) + domain = [] + if default_channel: + domain = [("id", "!=", default_channel.id)] + maxrule = self.search(domain, order="sequence desc", limit=1) + if maxrule: + return maxrule.sequence + 10 + else: + return 0 + + @api.constrains("code") + def _check_python_code(self): + for action in self.sudo().filtered("code"): + msg = test_python_expr(expr=action.code.strip(), mode="exec") + if msg: + raise exceptions.ValidationError(msg) + + def _prepare_domain(self): + domain = safe_eval(self.rule_domain) or [] + return domain + + def assign_release_channel(self, pickings): + pickings = pickings.filtered( + lambda picking: picking.picking_type_id.code == "outgoing" + and picking.state not in ("cancel", "done") + ) + if not pickings: + return + # do a single query rather than one for each rule*picking + for channel in self.sudo().search([]): + if channel.picking_type_ids: + current = pickings.filtered( + lambda p: p.picking_type_id in channel.picking_type_ids + ) + else: + current = pickings + + domain = channel._prepare_domain() + if domain: + current = current.filtered_domain(domain) + + if not current: + continue + + if channel.code: + current = channel._eval_code(current) + + if not current: + continue + + current.release_channel_id = channel + + pickings -= current + if not pickings: + break + + if pickings: + # by this point, all pickings should have been assigned + _logger.warning( + "%s transfers could not be assigned to a channel," + " you should add a final catch-all rule", + len(pickings), + ) + return True + + def _eval_context(self, pickings): + """Prepare the context used when for the python rule evaluation + + :returns: dict -- evaluation context given to (safe_)eval + """ + eval_context = { + "uid": self.env.uid, + "user": self.env.user, + "time": safe_time, + "datetime": safe_datetime, + "dateutil": safe_dateutil, + "timezone": timezone, + # orm + "env": self.env, + # record + "self": self, + "pickings": pickings, + } + return eval_context + + def _eval_code(self, pickings): + expr = self.code.strip() + eval_context = self._eval_context(pickings) + try: + safe_eval(expr, eval_context, mode="exec", nocopy=True) + except Exception as err: + raise exceptions.UserError( + _( + "Error when evaluating the channel's code:\n %(name)s \n(%(error)s)", + name=self.name, + error=err, + ) + ) from err + # normally "pickings" is always set as we set it in the eval context, + # but let assume the worst case with someone using "del pickings" + return eval_context.get("pickings", self.env["stock.picking"].browse()) + + def action_picking_all(self): + return self._action_picking_for_field( + "count_picking_all", context={"search_default_release_ready": 1} + ) + + def action_picking_release_ready(self): + return self._action_picking_for_field("count_picking_release_ready") + + def action_picking_released(self): + return self._action_picking_for_field("count_picking_released") + + def action_picking_assigned(self): + return self._action_picking_for_field("count_picking_assigned") + + def action_picking_waiting(self): + return self._action_picking_for_field("count_picking_waiting") + + def action_picking_late(self): + return self._action_picking_for_field("count_picking_late") + + def action_picking_priority(self): + return self._action_picking_for_field("count_picking_priority") + + def action_picking_done(self): + return self._action_picking_for_field("count_picking_done") + + def _action_picking_for_field(self, field_domain, context=None): + domain = self._field_picking_domains()[field_domain] + domain += [("release_channel_id", "in", self.ids)] + pickings = self.env["stock.picking"].search(domain) + field_descr = self._fields[field_domain]._description_string(self.env) + return self._build_action( + "stock_available_to_promise_release.stock_picking_release_action", + pickings, + field_descr, + context=context, + ) + + def action_move_all(self): + return self._action_move_for_field( + "count_picking_all", context={"search_default_release_ready": 1} + ) + + def action_move_release_ready(self): + return self._action_move_for_field("count_picking_release_ready") + + def action_move_released(self): + return self._action_move_for_field("count_picking_released") + + def action_move_assigned(self): + return self._action_move_for_field("count_picking_assigned") + + def action_move_waiting(self): + return self._action_move_for_field("count_picking_waiting") + + def action_move_late(self): + return self._action_move_for_field("count_picking_late") + + def action_move_priority(self): + return self._action_move_for_field("count_picking_priority") + + def action_move_done(self): + return self._action_move_for_field("count_picking_done") + + def _action_move_for_field(self, field_domain, context=None): + domain = self._field_picking_domains()[field_domain] + domain += [("release_channel_id", "in", self.ids)] + pickings = self.env["stock.picking"].search(domain) + field_descr = self._fields[field_domain]._description_string(self.env) + xmlid = "stock_available_to_promise_release.stock_move_release_action" + action = self.env["ir.actions.act_window"]._for_xml_id(xmlid) + action["display_name"] = "{} ({})".format( + ", ".join(self.mapped("display_name")), field_descr + ) + action["context"] = context if context else {} + action["domain"] = [("picking_id", "in", pickings.ids)] + return action + + def _build_action(self, xmlid, records, description, context=None): + action = self.env["ir.actions.act_window"]._for_xml_id(xmlid) + action["display_name"] = "{} ({})".format( + ", ".join(self.mapped("display_name")), description + ) + # we already have this column in the view's title, save space on the + # screen + base_context = {"hide_release_channel_id": True} + if context: + base_context.update(context) + action["context"] = base_context + action["domain"] = [("id", "in", records.ids)] + return action + + def action_picking_all_related(self): + """Open all chained transfers for released deliveries""" + return self._build_action( + "stock.action_picking_tree_all", + self.picking_chain_ids, + _("All Related Transfers"), + context={"search_default_available": 1, "search_default_picking_type": 1}, + ) + + def get_action_picking_form(self): + self.ensure_one() + action = self.env["ir.actions.act_window"]._for_xml_id( + "stock.action_picking_form" + ) + action["context"] = {} + if not self.last_done_picking_id: + raise exceptions.UserError( + _("Channel %(name)s has no validated transfer yet.", name=self.name) + ) + action["res_id"] = self.last_done_picking_id.id + return action + + @staticmethod + def _pickings_sort_key(picking): + return (-int(picking.priority or 1), picking.date_priority, picking.id) + + def _get_next_pickings(self): + return getattr(self, "_get_next_pickings_{}".format(self.auto_release))() + + def _get_next_pickings_max(self): + if not self.max_auto_release: + raise exceptions.UserError(_("No Max transfers to release is configured.")) + + waiting_domain = self._field_picking_domains()["count_picking_waiting"] + waiting_domain += [("release_channel_id", "=", self.id)] + released_in_progress = self.env["stock.picking"].search_count(waiting_domain) + + release_limit = max(self.max_auto_release - released_in_progress, 0) + if not release_limit: + raise exceptions.UserError( + _( + "The number of released transfers in" + " progress is already at the maximum." + ) + ) + domain = self._field_picking_domains()["count_picking_release_ready"] + domain += [("release_channel_id", "=", self.id)] + next_pickings = self.env["stock.picking"].search(domain) + # We have to use a python sort and not a order + limit on the search + # because "date_priority" is computed and not stored. If needed, we + # should evaluate making it a stored field in the module + # "stock_available_to_promise_release". + return next_pickings.sorted(self._pickings_sort_key)[:release_limit] + + def _get_next_pickings_group_commercial_partner(self): + domain = self._field_picking_domains()["count_picking_release_ready"] + domain += [("release_channel_id", "=", self.id)] + # We have to use a python sort and not a order + limit on the search + # because "date_priority" is computed and not stored. If needed, we + # should evaluate making it a stored field in the module + # "stock_available_to_promise_release". + next_pickings = ( + self.env["stock.picking"].search(domain).sorted(self._pickings_sort_key) + ) + if not next_pickings: + return self.env["stock.picking"].browse() + first_picking = next_pickings[0] + commercial_partner = first_picking.commercial_partner_id + partner_pickings = next_pickings.filtered( + lambda p: p.commercial_partner_id == commercial_partner + ) + return partner_pickings + + def release_next_batch(self): + self.ensure_one() + next_pickings = self._get_next_pickings() + if not next_pickings: + return { + "effect": { + "fadeout": "fast", + "message": _("Nothing in the queue!"), + "img_url": "/web/static/src/img/smile.svg", + "type": "rainbow_man", + } + } + next_pickings.release_available_to_promise() diff --git a/stock_release_channel/readme/CONFIGURE.rst b/stock_release_channel/readme/CONFIGURE.rst new file mode 100644 index 0000000000..73e4df43a7 --- /dev/null +++ b/stock_release_channel/readme/CONFIGURE.rst @@ -0,0 +1,2 @@ +In Inventory > Configuration > Release Channels. +Only Stock Managers have write permissions. diff --git a/stock_release_channel/readme/CONTRIBUTORS.rst b/stock_release_channel/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..3e31e70c52 --- /dev/null +++ b/stock_release_channel/readme/CONTRIBUTORS.rst @@ -0,0 +1,10 @@ +* Guewen Baconnier +* Matthieu Méquignon +* Sébastien Alix +* Jacques-Etienne Baudoux + +Design +~~~~~~ + +* Joël Grand-Guillaume +* Jacques-Etienne Baudoux diff --git a/stock_release_channel/readme/CREDITS.rst b/stock_release_channel/readme/CREDITS.rst new file mode 100644 index 0000000000..4641e10661 --- /dev/null +++ b/stock_release_channel/readme/CREDITS.rst @@ -0,0 +1,4 @@ +**Financial support** + +* Cosanum +* Camptocamp R&D diff --git a/stock_release_channel/readme/DESCRIPTION.rst b/stock_release_channel/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..8067784011 --- /dev/null +++ b/stock_release_channel/readme/DESCRIPTION.rst @@ -0,0 +1,15 @@ +Release channels are: + +* Release channels are created by stock managers (only pallets, only parcels, ...) +* A release channel has a sequence, a domain + possibility to use python code +* When a delivery is: created from a sales order / created as backorder / + released, each release channel is evaluated against it (domain + python code), + the delivery is assigned to the first channel that matches +* A release channel can change over time: for instance the evaluation of a + domain or rule can change if a delivery is only partially released +* A kanban board allows tracking how many [To Do Today, Released, Done Today, + Waiting, Late, Priority] Transfers are in each channel, plus quick access to + all the pick/pack transfers for released deliveries +* A button on each channel allows to release the next X (configured on the + channel) transfers (max X at a time, it releases X - currently released and + not done) diff --git a/stock_release_channel/readme/USAGE.rst b/stock_release_channel/readme/USAGE.rst new file mode 100644 index 0000000000..d1529d2720 --- /dev/null +++ b/stock_release_channel/readme/USAGE.rst @@ -0,0 +1,8 @@ +Use Inventory > Operations > Release Channels to access to the dashboard. + +Each channel has a dashboard with statistics about the number of transfers +to release and of the progress of the released transfers. + +When clicking on the "box" button, transfers are released automatically, to +reach a total of (option configured in the channel +settings). diff --git a/stock_release_channel/security/stock_release_channel.xml b/stock_release_channel/security/stock_release_channel.xml new file mode 100644 index 0000000000..991948df60 --- /dev/null +++ b/stock_release_channel/security/stock_release_channel.xml @@ -0,0 +1,34 @@ + + + + + stock.release.channel all users + + + + + + + + + + stock.release.channel stock users + + + + + + + + + + stock.release.channel stock managers + + + + + + + + + diff --git a/stock_release_channel/static/description/icon.png b/stock_release_channel/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/stock_release_channel/static/description/icon.png differ diff --git a/stock_release_channel/static/description/index.html b/stock_release_channel/static/description/index.html new file mode 100644 index 0000000000..dc15ae41d9 --- /dev/null +++ b/stock_release_channel/static/description/index.html @@ -0,0 +1,471 @@ + + + + + + +Stock Release Channels + + + +
+

Stock Release Channels

+ + +

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

+

Release channels are:

+
    +
  • Release channels are created by stock managers (only pallets, only parcels, …)
  • +
  • A release channel has a sequence, a domain + possibility to use python code
  • +
  • When a delivery is: created from a sales order / created as backorder / +released, each release channel is evaluated against it (domain + python code), +the delivery is assigned to the first channel that matches
  • +
  • A release channel can change over time: for instance the evaluation of a +domain or rule can change if a delivery is only partially released
  • +
  • A kanban board allows tracking how many [To Do Today, Released, Done Today, +Waiting, Late, Priority] Transfers are in each channel, plus quick access to +all the pick/pack transfers for released deliveries
  • +
  • A button on each channel allows to release the next X (configured on the +channel) transfers (max X at a time, it releases X - currently released and +not done)
  • +
+

Table of contents

+ +
+

Configuration

+

In Inventory > Configuration > Release Channels. +Only Stock Managers have write permissions.

+
+
+

Usage

+

Use Inventory > Operations > Release Channels to access to the dashboard.

+

Each channel has a dashboard with statistics about the number of transfers +to release and of the progress of the released transfers.

+

When clicking on the “box” button, transfers are released automatically, to +reach a total of <Max Transfers to release> (option configured in the channel +settings).

+
+
+

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 smashing it by providing a detailed and welcomed +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp
  • +
+
+
+

Contributors

+ +
+
+

Design

+ +
+
+

Other credits

+

Financial support

+
    +
  • Cosanum
  • +
  • Camptocamp R&D
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

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

+

Current maintainer:

+

sebalix

+

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/static/src/js/progressbar_fractional_widget.js b/stock_release_channel/static/src/js/progressbar_fractional_widget.js new file mode 100644 index 0000000000..3cd292fb76 --- /dev/null +++ b/stock_release_channel/static/src/js/progressbar_fractional_widget.js @@ -0,0 +1,83 @@ +odoo.define("stock_release_channel.progressbar_fractional_widget", function (require) { + "use strict"; + + var utils = require("web.utils"); + var basic_fields = require("web.basic_fields"); + var FieldProgressBar = basic_fields.FieldProgressBar; + + /** + * New node option: + * + * - show_fractional: Show always the numerical progress as / . + */ + FieldProgressBar.include({ + /** + * Extended so that we can choose when to show the values always + * as a fractional part. + */ + init: function () { + this._super.apply(this, arguments); + this.show_fractional = this.nodeOptions.show_fractional || false; + }, + + /** + * @override + * Overridden so that we show conditionally the result as a fraction. + * Before, it was shown as a fraction only if the denominator was + * not 100. Also: if denominator is zero, it's zero, and not 100 as + * in the core. + */ + _render_value: function (v) { + var value = this.value; + var max_value = this.max_value; + if (!isNaN(v)) { + if (this.edit_max_value) { + max_value = v; + } else { + value = v; + } + } + value = value || 0; + max_value = max_value || 0; + + // Variable initialised on declaration to silent the pre-commit. + // So the `else` part was removed. + var widthComplete = 100; + if (value <= max_value) { + widthComplete = (value / max_value) * 100; + } + + this.$(".o_progress") + .toggleClass("o_progress_overflow", value > max_value) + .attr("aria-valuemin", "0") + .attr("aria-valuemax", max_value) + .attr("aria-valuenow", value); + this.$(".o_progressbar_complete").css("width", widthComplete + "%"); + + if (!this.write_mode) { + // This `if` is a change made on this method w.r.t. the original one. + if (max_value !== 100 || this.show_fractional) { + // This variable is a change made w.r.t. the original one, + // because the original one set the value to 100 if the + // maximum value was zero. + var original_max_value = + this.recordData[this.nodeOptions.max_value]; + this.$(".o_progressbar_value").text( + utils.human_number(value) + + " / " + + utils.human_number(original_max_value) + ); + } else { + this.$(".o_progressbar_value").text( + utils.human_number(value) + "%" + ); + } + } else if (isNaN(v)) { + this.$(".o_progressbar_value").val( + this.edit_max_value ? max_value : value + ); + this.$(".o_progressbar_value").focus().select(); + } + }, + }); +}); diff --git a/stock_release_channel/static/src/scss/stock_release_channel.scss b/stock_release_channel/static/src/scss/stock_release_channel.scss new file mode 100644 index 0000000000..3381ac0755 --- /dev/null +++ b/stock_release_channel/static/src/scss/stock_release_channel.scss @@ -0,0 +1,38 @@ +.o_kanban_view.o_kanban_dashboard.o_stock_release_channel { + &.o_kanban_ungrouped { + .o_kanban_record { + width: 340px; + } + } + + .middle_block { + margin-top: $o-kanban-dashboard-vpadding; + margin-bottom: $o-kanban-dashboard-vpadding; + + border-top: 1px solid gray("300"); + border-bottom: 1px solid gray("300"); + background-color: gray("200"); + padding-top: $o-kanban-dashboard-vpadding; + padding-bottom: $o-kanban-dashboard-vpadding; + } + + .kanban_middle_title { + padding-right: 0; + padding-left: 0; + margin-bottom: $o-kanban-dashboard-vpadding; + } + + .middle_title { + @include o-kanban-record-title($font-size: 13px); + display: block; + } + + .primary_bottom_buttons { + .bottom_buttons_left, + .bottom_buttons_right { + display: flex; + align-items: center; + justify-content: center; + } + } +} diff --git a/stock_release_channel/tests/__init__.py b/stock_release_channel/tests/__init__.py new file mode 100644 index 0000000000..c2cd77fce9 --- /dev/null +++ b/stock_release_channel/tests/__init__.py @@ -0,0 +1,4 @@ +from . import test_release_channel +from . import test_channel_computed_fields +from . import test_channel_action +from . import test_channel_release_batch diff --git a/stock_release_channel/tests/common.py b/stock_release_channel/tests/common.py new file mode 100644 index 0000000000..2ab77c958a --- /dev/null +++ b/stock_release_channel/tests/common.py @@ -0,0 +1,158 @@ +# Copyright 2020 Camptocamp (https://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import fields +from odoo.tests import Form, common + +from odoo.addons.stock_available_to_promise_release.tests.common import ( + PromiseReleaseCommonCase, +) + + +class ReleaseChannelCase(common.TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.customer_location = cls.env.ref("stock.stock_location_customers") + cls.default_channel = cls.env.ref( + "stock_release_channel.stock_release_channel_default" + ) + cls._create_base_data() + + @classmethod + def _create_base_data(cls): + cls.wh = cls.env["stock.warehouse"].create( + { + "name": "Test Warehouse", + "reception_steps": "one_step", + "delivery_steps": "pick_ship", + "code": "WHTEST", + } + ) + cls.wh.delivery_route_id.write({"available_to_promise_defer_pull": True}) + with Form(cls.env["product.product"]) as product_form: + product_form.name = ("Test Product 1",) + product_form.barcode = "test" + cls.product1 = product_form.save() + with Form(cls.env["product.product"]) as product_form: + product_form.name = ("Test Product 2",) + product_form.barcode = "test2" + cls.product2 = product_form.save() + + @classmethod + def _update_qty_in_location( + cls, location, product, quantity, package=None, lot=None, in_date=None + ): + quants = cls.env["stock.quant"]._gather( + product, location, lot_id=lot, package_id=package, strict=True + ) + # this method adds the quantity to the current quantity, so remove it + quantity -= sum(quants.mapped("quantity")) + cls.env["stock.quant"]._update_available_quantity( + product, + location, + quantity, + package_id=package, + lot_id=lot, + in_date=in_date, + ) + + def _create_single_move(self, product, qty, group=None): + # create a group so different moves are not merged in + # the same picking + if not group: + group = self.env["procurement.group"].create({}) + picking_type = self.wh.out_type_id + move_vals = { + "name": product.name, + "picking_type_id": picking_type.id, + "product_id": product.id, + "product_uom_qty": qty, + "product_uom": product.uom_id.id, + "location_id": picking_type.default_location_src_id.id, + "location_dest_id": self.customer_location.id, + "state": "confirmed", + "procure_method": "make_to_stock", + "group_id": group.id, + } + move = self.env["stock.move"].create(move_vals) + move._assign_picking() + return move + + def _create_channel(self, **vals): + return self.env["stock.release.channel"].create(vals) + + def _run_procurement(self, move, date=None): + values = { + "company_id": self.wh.company_id, + "group_id": move.picking_id.group_id, + "date_planned": date or fields.Datetime.now(), + "warehouse_id": self.wh, + } + self.env["procurement.group"].run( + [ + self.env["procurement.group"].Procurement( + move.product_id, + move.product_uom_qty, + move.product_uom, + self.customer_location, + "TEST", + "TEST", + self.wh.company_id, + values, + ) + ] + ) + + +class ChannelReleaseCase(PromiseReleaseCommonCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.commercial_partner = cls.env["res.partner"].create({"name": "Main Company"}) + cls.delivery_address_1 = cls.env["res.partner"].create( + {"name": "Delivery 1", "parent_id": cls.commercial_partner.id} + ) + cls.delivery_address_2 = cls.env["res.partner"].create( + {"name": "Delivery 2", "parent_id": cls.commercial_partner.id} + ) + cls.other_partner = cls.env["res.partner"].create({"name": "Partner 2"}) + + cls.wh.delivery_route_id.write({"available_to_promise_defer_pull": True}) + cls.picking = cls._out_picking( + cls._create_picking_chain( + cls.wh, + [(cls.product1, 5), (cls.product2, 5)], + move_type="direct", + ) + ) + cls.picking.partner_id = cls.delivery_address_1 + cls.picking2 = cls._out_picking( + cls._create_picking_chain( + cls.wh, + [(cls.product1, 5), (cls.product2, 5)], + move_type="direct", + ) + ) + cls.picking2.partner_id = cls.delivery_address_2 + cls.picking3 = cls._out_picking( + cls._create_picking_chain( + cls.wh, + [(cls.product1, 5), (cls.product2, 5)], + move_type="direct", + ) + ) + cls.picking3.partner_id = cls.other_partner + (cls.picking + cls.picking2 + cls.picking3).assign_release_channel() + + cls.channel = cls.picking.release_channel_id + + @classmethod + def _out_picking(cls, pickings): + return pickings.filtered(lambda r: r.picking_type_code == "outgoing") + + def _action_done_picking(self, picking): + for line in picking.move_line_ids: + line.qty_done = line.reserved_qty + picking._action_done() diff --git a/stock_release_channel/tests/test_channel_action.py b/stock_release_channel/tests/test_channel_action.py new file mode 100644 index 0000000000..c054523964 --- /dev/null +++ b/stock_release_channel/tests/test_channel_action.py @@ -0,0 +1,138 @@ +# Copyright 2020 Camptocamp (https://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from datetime import timedelta + +from odoo import exceptions, fields + +from .common import ChannelReleaseCase + + +class TestChannelAction(ChannelReleaseCase): + def _assert_picking_action(self, action, pickings, context=None): + self.assertEqual(action["domain"][0][0], "id") + self.assertEqual(action["domain"][0][1], "in") + self.assertEqual(set(action["domain"][0][2]), set(pickings.ids)) + self.assertEqual( + action["context"], dict(context or {}, hide_release_channel_id=True) + ) + + def _assert_move_action(self, action, pickings, context=None): + self.assertEqual(action["domain"][0][0], "picking_id") + self.assertEqual(action["domain"][0][1], "in") + self.assertEqual(set(action["domain"][0][2]), set(pickings.ids)) + self.assertEqual(action["context"], context or {}) + + def test_action_all(self): + self._assert_picking_action( + self.channel.action_picking_all(), + self.picking + self.picking2 + self.picking3, + {"search_default_release_ready": 1}, + ) + self._assert_move_action( + self.channel.action_move_all(), + self.picking + self.picking2 + self.picking3, + {"search_default_release_ready": 1}, + ) + + def test_action_release_forbidden(self): + self.channel.release_forbidden = True + with self.assertRaises(exceptions.UserError): + self.picking.release_available_to_promise() + self.channel.release_forbidden = False + self.picking.release_available_to_promise() + + def test_action_release_ready(self): + self._update_qty_in_location(self.loc_bin1, self.product1, 10.0) + self._update_qty_in_location(self.loc_bin1, self.product2, 10.0) + + self._assert_picking_action( + self.channel.action_picking_release_ready(), self.picking + self.picking2 + ) + self._assert_move_action( + self.channel.action_move_release_ready(), self.picking + self.picking2 + ) + + def test_action_released_assigned_waiting(self): + self._update_qty_in_location(self.loc_bin1, self.product1, 10.0) + self._update_qty_in_location(self.loc_bin1, self.product2, 10.0) + self.picking.release_available_to_promise() + self.picking2.release_available_to_promise() + + self._assert_picking_action( + self.channel.action_picking_released(), self.picking + self.picking2 + ) + self._assert_move_action( + self.channel.action_move_released(), self.picking + self.picking2 + ) + + pick_picking = self.picking.move_ids.move_orig_ids.picking_id + self._action_done_picking(pick_picking) + + self._assert_picking_action( + self.channel.action_picking_assigned(), self.picking + ) + self._assert_move_action(self.channel.action_move_assigned(), self.picking) + + self._assert_picking_action( + self.channel.action_picking_waiting(), self.picking2 + ) + self._assert_move_action(self.channel.action_move_waiting(), self.picking2) + + def _release_all(self): + self._update_qty_in_location(self.loc_bin1, self.product1, 15.0) + self._update_qty_in_location(self.loc_bin1, self.product2, 15.0) + (self.picking + self.picking2 + self.picking3).release_available_to_promise() + + def test_action_all_related(self): + self._release_all() + pickings = self.picking + self.picking2 + self.picking3 + related = pickings.move_ids.move_orig_ids.picking_id + + action = self.channel.action_picking_all_related() + self._assert_picking_action( + action, + related, + {"search_default_available": 1, "search_default_picking_type": 1}, + ) + + def test_action_late(self): + self._release_all() + self.picking.scheduled_date = fields.Datetime.now() - timedelta(hours=1) + self.picking2.scheduled_date = fields.Datetime.now() - timedelta(hours=1) + self._assert_picking_action( + self.channel.action_picking_late(), self.picking + self.picking2 + ) + self._assert_move_action( + self.channel.action_move_late(), self.picking + self.picking2 + ) + + def test_action_priority(self): + self._release_all() + self.picking.priority = "1" + self.picking2.priority = "1" + self._assert_picking_action( + self.channel.action_picking_priority(), self.picking + self.picking2 + ) + self._assert_move_action( + self.channel.action_move_priority(), self.picking + self.picking2 + ) + + def test_action_done(self): + self._release_all() + self._action_done_picking(self.picking.move_ids.move_orig_ids.picking_id) + self._action_done_picking(self.picking) + + self._assert_picking_action(self.channel.action_picking_done(), self.picking) + self._assert_move_action(self.channel.action_move_done(), self.picking) + + def test_action_no_last_picking_done(self): + with self.assertRaises(exceptions.UserError): + self.channel.get_action_picking_form() + + def test_action_last_picking_done(self): + self._release_all() + self._action_done_picking(self.picking.move_ids.move_orig_ids.picking_id) + self._action_done_picking(self.picking) + action = self.channel.get_action_picking_form() + self.assertEqual(action["res_id"], self.picking.id) diff --git a/stock_release_channel/tests/test_channel_computed_fields.py b/stock_release_channel/tests/test_channel_computed_fields.py new file mode 100644 index 0000000000..f33dee3c5f --- /dev/null +++ b/stock_release_channel/tests/test_channel_computed_fields.py @@ -0,0 +1,74 @@ +# Copyright 2020 Camptocamp (https://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from datetime import timedelta + +from odoo import fields + +from .common import ChannelReleaseCase + + +class TestChannelComputedFields(ChannelReleaseCase): + def test_computed_fields_counts(self): + picking = self.picking + channel = self.channel + + self.assertEqual(channel.count_picking_all, 3) + self.assertEqual(channel.count_move_all, 6) + self.assertEqual(channel.count_picking_release_ready, 0) + self.assertEqual(channel.count_picking_released, 0) + self.assertEqual(channel.count_picking_assigned, 0) + self.assertEqual(channel.count_picking_waiting, 0) + self.assertEqual(channel.count_picking_late, 0) + self.assertEqual(channel.count_picking_priority, 0) + self.assertEqual(channel.count_picking_done, 0) + self.assertEqual(channel.count_picking_chain, 0) + self.assertEqual(channel.count_picking_chain_in_progress, 0) + + self._update_qty_in_location(self.loc_bin1, self.product1, 20.0) + self._update_qty_in_location(self.loc_bin1, self.product2, 20.0) + + channel.env.invalidate_all() + self.assertEqual(channel.count_picking_release_ready, 3) + + picking.release_available_to_promise() + pick_picking = picking.move_ids.move_orig_ids.picking_id + + channel.env.invalidate_all() + + self.assertEqual(channel.count_picking_all, 3) + self.assertEqual(channel.count_move_all, 6) + self.assertEqual(channel.count_picking_release_ready, 2) + self.assertEqual(channel.count_move_release_ready, 4) + self.assertEqual(channel.count_picking_released, 1) + self.assertEqual(channel.count_move_released, 2) + self.assertEqual(channel.count_picking_assigned, 0) + self.assertEqual(channel.count_picking_waiting, 1) + self.assertEqual(channel.count_move_waiting, 2) + self.assertEqual(channel.count_picking_late, 0) + self.assertEqual(channel.count_picking_priority, 0) + self.assertEqual(channel.count_picking_done, 0) + self.assertEqual(channel.count_picking_chain, 1) + self.assertEqual(channel.count_picking_chain_in_progress, 1) + self.assertEqual(channel.picking_chain_ids, pick_picking) + + picking.scheduled_date = fields.Datetime.now() - timedelta(hours=1) + channel.env.invalidate_all() + self.assertEqual(channel.count_picking_late, 1) + self.assertEqual(channel.count_move_late, 2) + + self._action_done_picking(pick_picking) + + channel.env.invalidate_all() + self.assertEqual(channel.count_picking_assigned, 1) + self.assertEqual(channel.count_move_assigned, 2) + self._action_done_picking(picking) + + channel.env.invalidate_all() + self.assertEqual(channel.count_picking_done, 1) + self.assertEqual(channel.count_picking_chain, 0) + self.assertEqual(channel.count_picking_chain_in_progress, 0) + + self.assertEqual(channel.last_done_picking_id, picking) + self.assertEqual(channel.last_done_picking_name, picking.name) + self.assertEqual(channel.last_done_picking_date_done, picking.date_done) diff --git a/stock_release_channel/tests/test_channel_release_batch.py b/stock_release_channel/tests/test_channel_release_batch.py new file mode 100644 index 0000000000..9caef113f3 --- /dev/null +++ b/stock_release_channel/tests/test_channel_release_batch.py @@ -0,0 +1,87 @@ +# Copyright 2020 Camptocamp (https://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import _, exceptions + +from .common import ChannelReleaseCase + + +class TestChannelReleaseBatch(ChannelReleaseCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.pickings = cls.picking + cls.picking2 + cls.picking3 + for __ in range(3): + delivery = cls._out_picking( + cls._create_picking_chain( + cls.wh, [(cls.product1, 5), (cls.product2, 5)], move_type="direct" + ) + ) + delivery.partner_id = cls.other_partner + cls.pickings += delivery + cls._update_qty_in_location(cls.loc_bin1, cls.product1, 1000.0) + cls._update_qty_in_location(cls.loc_bin1, cls.product2, 1000.0) + + def test_release_auto_forbidden(self): + self.channel.release_forbidden = True + with self.assertRaises(exceptions.UserError): + self.channel.release_next_batch() + + def test_release_auto_max_next_batch_no_config(self): + self.channel.max_auto_release = 0 + with self.assertRaises(exceptions.UserError): + self.channel.release_next_batch() + + def test_release_auto_max_next_batch(self): + self.channel.max_auto_release = 2 + self.channel.release_next_batch() + # 2 have been released + self.assertEqual( + self.pickings.mapped("need_release"), [False, False, True, True, True, True] + ) + + with self.assertRaises(exceptions.UserError): + # nothing new to release + self.channel.release_next_batch() + + self._action_done_picking(self.pickings[0].move_ids.move_orig_ids.picking_id) + self._action_done_picking(self.pickings[0]) + + self.channel.release_next_batch() + # 1 have been released to reach the max of 2 + self.assertEqual( + self.pickings.mapped("need_release"), + [False, False, False, True, True, True], + ) + + def test_release_auto_max_no_next_batch(self): + self.pickings.need_release = False # cheat for getting the right condition + action = self.channel.release_next_batch() + self._assert_action_nothing_in_the_queue(action) + + def test_release_auto_group_commercial_partner(self): + self.channel.auto_release = "group_commercial_partner" + self.channel.release_next_batch() + self.assertFalse(self.picking.need_release) + self.assertFalse(self.picking2.need_release) + other_pickings = self.pickings - (self.picking | self.picking2) + self.assertTrue(all(p.need_release) for p in other_pickings) + + def test_release_auto_group_commercial_partner_no_next_batch(self): + self.channel.auto_release = "group_commercial_partner" + self.pickings.need_release = False # cheat for getting the right condition + action = self.channel.release_next_batch() + self._assert_action_nothing_in_the_queue(action) + + def _assert_action_nothing_in_the_queue(self, action): + self.assertEqual( + action, + { + "effect": { + "fadeout": "fast", + "message": _("Nothing in the queue!"), + "img_url": "/web/static/src/img/smile.svg", + "type": "rainbow_man", + } + }, + ) diff --git a/stock_release_channel/tests/test_release_channel.py b/stock_release_channel/tests/test_release_channel.py new file mode 100644 index 0000000000..492e19a11b --- /dev/null +++ b/stock_release_channel/tests/test_release_channel.py @@ -0,0 +1,58 @@ +# Copyright 2020 Camptocamp (https://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + + +from odoo import exceptions + +from .common import ReleaseChannelCase + + +class TestReleaseChannel(ReleaseChannelCase): + def _test_assign_channels(self, expected): + move = self._create_single_move(self.product1, 10) + move.picking_id.priority = "1" + move2 = self._create_single_move(self.product2, 10) + (move + move2).picking_id.assign_release_channel() + self.assertEqual(move.picking_id.release_channel_id, expected) + self.assertEqual(move2.picking_id.release_channel_id, self.default_channel) + + def test_assign_channel_domain(self): + channel = self._create_channel( + name="Test Domain", + sequence=1, + rule_domain=[("priority", "=", "1")], + ) + self._test_assign_channels(channel) + + def test_assign_channel_code(self): + channel = self._create_channel( + name="Test Code", + sequence=1, + code="pickings = pickings.filtered(lambda p: p.priority == '1')", + ) + self._test_assign_channels(channel) + + def test_assign_channel_domain_and_code(self): + channel = self._create_channel( + name="Test Code", + sequence=1, + rule_domain=[("priority", "=", "1")], + code="pickings = pickings.filtered(lambda p: p.priority == '1')", + ) + self._test_assign_channels(channel) + + def test_invalid_code(self): + with self.assertRaises(exceptions.ValidationError): + self._create_channel( + name="Test Code", + sequence=1, + code="pickings = pickings.filtered(", + ) + + def test_default_sequence(self): + channel = self._create_channel(name="Test1") + self.assertEqual(channel.sequence, 0) + channel2 = self._create_channel(name="Test2") + self.assertEqual(channel2.sequence, 10) + channel3 = self._create_channel(name="Test3") + self.assertEqual(channel3.sequence, 20) diff --git a/stock_release_channel/views/stock_picking_views.xml b/stock_release_channel/views/stock_picking_views.xml new file mode 100644 index 0000000000..eb0d0dd7bd --- /dev/null +++ b/stock_release_channel/views/stock_picking_views.xml @@ -0,0 +1,73 @@ + + + + + stock.picking.form + stock.picking + + + + + + + + + + stock.picking.internal.search + stock.picking + + + + + + + + + + + + + + + + + + + + + stock.picking.release.tree + stock.picking + + + + + + + + + + + + diff --git a/stock_release_channel/views/stock_release_channel_views.xml b/stock_release_channel/views/stock_release_channel_views.xml new file mode 100644 index 0000000000..d124446fd1 --- /dev/null +++ b/stock_release_channel/views/stock_release_channel_views.xml @@ -0,0 +1,523 @@ + + + + + stock.release.channel.form + stock.release.channel + +
+ +
+
+ + +
+
+
+
+ + + stock.release.channel.search + stock.release.channel + + + + + + + + + + + + + stock.release.channel.tree + stock.release.channel + + + + + + + + + + + + + + + + + + + + stock.release.channel.kanban + stock.release.channel + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+ +
+
+ + +
+ +
+ + + [~] + +
+
+ + +
+
+ +
+ +
+ + +
+ +
+ +
+ To Do Situation +
+
+ +
+
+ +
+
+
+ +
+ + + [~] + +
+
+ +
+ +
+ + + [~] + +
+
+ + + +
+ + +
+ + + +
+ +
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+ + +
+ + +
+
+
+ +
+
+
+
+ + + Release Channels + stock.release.channel + kanban,tree,form + [] + {} + +

+ No Release Channel configured +

+
+
+ + + Release Channels + stock.release.channel + tree,form,kanban + [] + {} + +

+ Add a Release Channel +

+
+
+ + + Release Channels + + + + + + + Release Channels + + + + + +