diff --git a/setup/stock_warehouse_flow/odoo/addons/stock_warehouse_flow b/setup/stock_warehouse_flow/odoo/addons/stock_warehouse_flow new file mode 120000 index 0000000000..d67ef2312c --- /dev/null +++ b/setup/stock_warehouse_flow/odoo/addons/stock_warehouse_flow @@ -0,0 +1 @@ +../../../../stock_warehouse_flow \ No newline at end of file diff --git a/setup/stock_warehouse_flow/setup.py b/setup/stock_warehouse_flow/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/stock_warehouse_flow/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/stock_warehouse_flow/README.rst b/stock_warehouse_flow/README.rst new file mode 100644 index 0000000000..f6073ca559 --- /dev/null +++ b/stock_warehouse_flow/README.rst @@ -0,0 +1,132 @@ +==================== +Stock Warehouse Flow +==================== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |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/14.0/stock_warehouse_flow + :alt: OCA/wms +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/wms-14-0/wms-14-0-stock_warehouse_flow + :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/14.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module introduces the concept of routing flows in order to manage +different delivery routes for a warehouse. + +The default behavior of Odoo allows you to have only one delivery route per +warehouse (with one, two or three steps). +With this module, you are now able to manage multiple delivery routes (having +their own rules and operation types), the right one being selected automatically +based on some criterias, like the carrier and any attribute of the stock move +to process. + +This allows you to define a delivery route based on the type of goods to ship, +for instance: + +* whole pallet (pick + ship) +* cold chain goods +* dangerous goods + +.. image:: https://raw.githubusercontent.com/OCA/wms/14.0/stock_warehouse_flow/static/description/flow.png + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Got to "Inventory > Settings > Routing Flows". + +A routing flow can be seen as a helper to generate a delivery route (like the +warehouse is doing automatically). The new route will get its own rules and +operation types that doesn't overlap with the default ones of the warehouse. + +A routing flow is responsible to change the warehouse delivery route of a move +by another one depending on some criterias: + +* the initial outgoing operation type (usually the default one) +* the carrier +* a custom domain (applied on the move) + +This way you are able to change the route a move will take depending on its +carrier and, for instance, the type or the packaging of the product +you want to ship. + +.. image:: https://raw.githubusercontent.com/OCA/wms/14.0/stock_warehouse_flow/static/description/config.png + +Usage +===== + +When a stock move is confirmed, if a flow is matching all the criteria then +the new delivery route will be automatically applied. + +Known issues / Roadmap +====================== + +Currently, the module supports only delivery routes, but it could improved to +support reception routes as well. + +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 +* BCIM + +Contributors +~~~~~~~~~~~~ + +* Sébastien Alix +* Jacques-Etienne Baudoux +* Michael Tietz (MT Software) + +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_warehouse_flow/__init__.py b/stock_warehouse_flow/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/stock_warehouse_flow/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/stock_warehouse_flow/__manifest__.py b/stock_warehouse_flow/__manifest__.py new file mode 100644 index 0000000000..2ea0ca9f77 --- /dev/null +++ b/stock_warehouse_flow/__manifest__.py @@ -0,0 +1,30 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +{ + "name": "Stock Warehouse Flow", + "summary": "Configure routing flow for stock moves", + "author": "Camptocamp, BCIM, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/wms", + "category": "Warehouse Management", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "depends": [ + # core + "stock", + "delivery", + # OCA/stock-logistics-workflow + "delivery_procurement_group_carrier", + ], + "demo": [ + "demo/stock_warehouse_flow.xml", + ], + "data": [ + "security/ir.model.access.csv", + "views/stock_route.xml", + "views/stock_warehouse_flow.xml", + "views/stock_warehouse.xml", + "views/delivery_carrier.xml", + ], + "installable": True, + "development_status": "Alpha", +} diff --git a/stock_warehouse_flow/demo/stock_warehouse_flow.xml b/stock_warehouse_flow/demo/stock_warehouse_flow.xml new file mode 100644 index 0000000000..6467296d58 --- /dev/null +++ b/stock_warehouse_flow/demo/stock_warehouse_flow.xml @@ -0,0 +1,38 @@ + + + + + + Delivery Ship Only + + + ship_only + DIRECT + + + + The Poste - Delivery Pick Ship + + + pick_ship + POST + + + + + Normal - Delivery Pick Pack Ship + + + pick_pack_ship + NORMAL + + + + diff --git a/stock_warehouse_flow/i18n/stock_warehouse_flow.pot b/stock_warehouse_flow/i18n/stock_warehouse_flow.pot new file mode 100644 index 0000000000..e22a65c5d0 --- /dev/null +++ b/stock_warehouse_flow/i18n/stock_warehouse_flow.pot @@ -0,0 +1,413 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * stock_warehouse_flow +# +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_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__active +msgid "Active" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_location_route__applicable_flow_ids +msgid "Applicable Flows" +msgstr "" + +#. module: stock_warehouse_flow +#: model_terms:ir.ui.view,arch_db:stock_warehouse_flow.stock_warehouse_flow_view_form +#: model_terms:ir.ui.view,arch_db:stock_warehouse_flow.stock_warehouse_flow_view_search +msgid "Archived" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__company_id +msgid "Company" +msgstr "" + +#. module: stock_warehouse_flow +#: model_terms:ir.actions.act_window,help:stock_warehouse_flow.stock_warehouse_flow_action +msgid "Create a new Routing Flow" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__create_uid +msgid "Created by" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__create_date +msgid "Created on" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields.selection,name:stock_warehouse_flow.selection__stock_warehouse_flow__delivery_steps__ship_only +msgid "Deliver goods directly (1 step)" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__delivery_route_id +msgid "Delivery Route" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_delivery_carrier__display_name +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_location_route__display_name +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_move__display_name +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse__display_name +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__display_name +msgid "Display Name" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,help:stock_warehouse_flow.field_stock_warehouse_flow__move_domain +msgid "" +"Domain based on Stock Moves, to define if the routing flow is applicable or " +"not." +msgstr "" + +#. module: stock_warehouse_flow +#: code:addons/stock_warehouse_flow/models/stock_warehouse_flow.py:0 +#, python-format +msgid "Existing flow '%s' already applies on these kind of moves." +msgstr "" + +#. module: stock_warehouse_flow +#: model_terms:ir.ui.view,arch_db:stock_warehouse_flow.stock_warehouse_flow_view_form +msgid "Flow Name" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse__flow_ids +msgid "Flows" +msgstr "" + +#. module: stock_warehouse_flow +#: model_terms:ir.ui.view,arch_db:stock_warehouse_flow.stock_warehouse_flow_view_form +msgid "From" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__from_location_dest_id +msgid "From Location Dest" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__from_location_src_id +msgid "From Location Src" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__from_picking_type_id +#: model_terms:ir.ui.view,arch_db:stock_warehouse_flow.stock_warehouse_flow_view_search +msgid "From operation type" +msgstr "" + +#. module: stock_warehouse_flow +#: model_terms:ir.ui.view,arch_db:stock_warehouse_flow.stock_warehouse_flow_view_form +msgid "Generate route" +msgstr "" + +#. module: stock_warehouse_flow +#: model_terms:ir.ui.view,arch_db:stock_warehouse_flow.stock_warehouse_flow_view_search +msgid "Group by..." +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_delivery_carrier__id +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_location_route__id +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_move__id +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse__id +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__id +msgid "ID" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,help:stock_warehouse_flow.field_stock_warehouse_flow__qty +msgid "" +"If a qty is set the flow can be applied on moves where the move's qty >= the" +" qty set on the flow\n" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__impacted_route_ids +msgid "Impacted Routes" +msgstr "" + +#. module: stock_warehouse_flow +#: model_terms:ir.ui.view,arch_db:stock_warehouse_flow.stock_warehouse_flow_view_form +msgid "Impacted routes" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model,name:stock_warehouse_flow.model_stock_location_route +msgid "Inventory Routes" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_delivery_carrier____last_update +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_location_route____last_update +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_move____last_update +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse____last_update +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow____last_update +msgid "Last Modified on" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__write_date +msgid "Last Updated on" +msgstr "" + +#. module: stock_warehouse_flow +#: model_terms:ir.ui.view,arch_db:stock_warehouse_flow.stock_warehouse_flow_view_form +msgid "Locations" +msgstr "" + +#. module: stock_warehouse_flow +#: model_terms:ir.ui.view,arch_db:stock_warehouse_flow.stock_warehouse_flow_view_form +msgid "Max quantity multiple" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__name +msgid "Name" +msgstr "" + +#. module: stock_warehouse_flow +#: code:addons/stock_warehouse_flow/models/stock_warehouse_flow.py:0 +#, python-format +msgid "No routing flow available for the move {move} in transfer {picking}." +msgstr "" + +#. module: stock_warehouse_flow +#: code:addons/stock_warehouse_flow/models/stock_warehouse_flow.py:0 +#, python-format +msgid "" +"No rule corresponding to '%(picking_type)s' operation type has been found on delivery route '%(delivery_route)s'.\n" +"Please check your configuration." +msgstr "" + +#. module: stock_warehouse_flow +#: model_terms:ir.ui.view,arch_db:stock_warehouse_flow.stock_warehouse_flow_view_form +msgid "Operation Types" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__out_type_id +msgid "Out Type" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__delivery_steps +msgid "Outgoing Shipments" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,help:stock_warehouse_flow.field_stock_warehouse_flow__delivery_steps +msgid "Outgoing route to follow" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__output_stock_loc_id +msgid "Output Location" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__pack_type_id +msgid "Pack Type" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields.selection,name:stock_warehouse_flow.selection__stock_warehouse_flow__delivery_steps__pick_pack_ship +msgid "Pack goods, send goods in output and then deliver (3 steps)" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__pack_stock_loc_id +msgid "Packing Location" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__pick_type_id +msgid "Pick Type" +msgstr "" + +#. module: stock_warehouse_flow +#: code:addons/stock_warehouse_flow/models/stock_warehouse_flow.py:0 +#, python-format +msgid "Please set the uom field in addition to the qty field" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__qty +msgid "Qty" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_location_route__flow_id +msgid "Routing Flow" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.actions.act_window,name:stock_warehouse_flow.stock_warehouse_flow_action +#: model:ir.ui.menu,name:stock_warehouse_flow.stock_warehouse_flow_menu +#: model_terms:ir.ui.view,arch_db:stock_warehouse_flow.stock_warehouse_flow_view_search +#: model_terms:ir.ui.view,arch_db:stock_warehouse_flow.view_delivery_carrier_form +#: model_terms:ir.ui.view,arch_db:stock_warehouse_flow.view_warehouse +msgid "Routing Flows" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_delivery_carrier__flow_ids +msgid "Routing flows" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__rule_ids +#: model_terms:ir.ui.view,arch_db:stock_warehouse_flow.stock_warehouse_flow_view_form +msgid "Rules" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,help:stock_warehouse_flow.field_stock_warehouse_flow__out_type_id +msgid "" +"Same than 'To operation type' field, displayed here to get a global overview" +" of the flow configuration." +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,help:stock_warehouse_flow.field_stock_warehouse_flow__output_stock_loc_id +msgid "" +"Same than 'To output location' field, displayed here to get a global " +"overview of the flow configuration." +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields.selection,name:stock_warehouse_flow.selection__stock_warehouse_flow__delivery_steps__pick_ship +msgid "Send goods in output and then deliver (2 steps)" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__sequence +msgid "Sequence" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__sequence_prefix +msgid "Sequence Prefix" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model,name:stock_warehouse_flow.model_delivery_carrier +msgid "Shipping Methods" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields.selection,name:stock_warehouse_flow.selection__stock_warehouse_flow__split_method__simple +msgid "Simple" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,help:stock_warehouse_flow.field_stock_warehouse_flow__split_method +msgid "" +"Simple => move will be split by the qty of the flow or a multiple of it" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__move_domain +msgid "Source Routing Domain" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__split_method +msgid "Split method" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model,name:stock_warehouse_flow.model_stock_move +msgid "Stock Move" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model,name:stock_warehouse_flow.model_stock_warehouse_flow +msgid "Stock Warehouse Routing Flow" +msgstr "" + +#. module: stock_warehouse_flow +#: model_terms:ir.ui.view,arch_db:stock_warehouse_flow.stock_warehouse_flow_view_form +msgid "Technical Information" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,help:stock_warehouse_flow.field_stock_warehouse_flow__company_id +msgid "The company is automatically set from your user preferences." +msgstr "" + +#. module: stock_warehouse_flow +#: model_terms:ir.ui.view,arch_db:stock_warehouse_flow.stock_warehouse_flow_view_form +msgid "To" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__to_picking_type_id +#: model_terms:ir.ui.view,arch_db:stock_warehouse_flow.stock_warehouse_flow_view_search +msgid "To operation type" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__to_output_stock_loc_id +msgid "To output location" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__uom_id +msgid "Uom" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,help:stock_warehouse_flow.field_stock_warehouse_flow__sequence_prefix +msgid "" +"Used to generate the default prefix of new operation types. (e.g. 'DG' => " +"'WH/OUT/DG/')" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model,name:stock_warehouse_flow.model_stock_warehouse +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__warehouse_id +#: model_terms:ir.ui.view,arch_db:stock_warehouse_flow.stock_warehouse_flow_view_search +msgid "Warehouse" +msgstr "" + +#. module: stock_warehouse_flow +#: model_terms:ir.ui.view,arch_db:stock_warehouse_flow.stock_warehouse_flow_view_form +msgid "Warehouse Flow" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__warning +msgid "Warning" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__carrier_ids +msgid "With carriers" +msgstr "" + +#. module: stock_warehouse_flow +#: model_terms:ir.ui.view,arch_db:stock_warehouse_flow.stock_warehouse_flow_view_form +msgid "to" +msgstr "" diff --git a/stock_warehouse_flow/models/__init__.py b/stock_warehouse_flow/models/__init__.py new file mode 100644 index 0000000000..81ef39aad3 --- /dev/null +++ b/stock_warehouse_flow/models/__init__.py @@ -0,0 +1,5 @@ +from . import stock_warehouse_flow +from . import stock_warehouse +from . import stock_move +from . import stock_route +from . import delivery_carrier diff --git a/stock_warehouse_flow/models/delivery_carrier.py b/stock_warehouse_flow/models/delivery_carrier.py new file mode 100644 index 0000000000..675feb6269 --- /dev/null +++ b/stock_warehouse_flow/models/delivery_carrier.py @@ -0,0 +1,26 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import fields, models + + +class DeliveryCarrier(models.Model): + _inherit = "delivery.carrier" + + flow_ids = fields.Many2many( + comodel_name="stock.warehouse.flow", + relation="delivery_carrier_stock_warehouse_flow_rel", + column1="delivery_carrier_id", + column2="stock_warehouse_flow_id", + string="Routing flows", + copy=False, + ) + + def action_view_flows(self): + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "stock_warehouse_flow.stock_warehouse_flow_action" + ) + action["domain"] = [("id", "in", self.flow_ids.ids)] + action["context"] = {"default_carrier_ids": [(6, 0, self.ids)]} + return action diff --git a/stock_warehouse_flow/models/stock_move.py b/stock_warehouse_flow/models/stock_move.py new file mode 100644 index 0000000000..d8ad94c7a2 --- /dev/null +++ b/stock_warehouse_flow/models/stock_move.py @@ -0,0 +1,27 @@ +# Copyright 2022 Camptocamp SA +# Copyright 2023 Michael Tietz (MT Software) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import models + + +class StockMove(models.Model): + _inherit = "stock.move" + + def _action_confirm(self, merge=True, merge_into=False): + # Apply the flow configuration on the move before it generates + # its chained moves (if any) + FLOW = self.env["stock.warehouse.flow"] + move_ids_to_confirm = [] + for move in self: + if not move._apply_flow_on_action_confirm(): + move_ids_to_confirm.append(move.id) + continue + move_ids_to_confirm += FLOW._search_and_apply_for_move(move).ids + moves_to_confirm = self.browse(move_ids_to_confirm) + return super(StockMove, moves_to_confirm)._action_confirm( + merge=merge, merge_into=merge_into + ) + + def _apply_flow_on_action_confirm(self): + return self.picking_type_id.code == "outgoing" diff --git a/stock_warehouse_flow/models/stock_route.py b/stock_warehouse_flow/models/stock_route.py new file mode 100644 index 0000000000..f5185927ea --- /dev/null +++ b/stock_warehouse_flow/models/stock_route.py @@ -0,0 +1,29 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import api, fields, models + + +class StockRoute(models.Model): + _inherit = "stock.route" + + flow_id = fields.Many2one( + comodel_name="stock.warehouse.flow", + ondelete="set null", + string="Routing Flow", + ) + applicable_flow_ids = fields.One2many( + comodel_name="stock.warehouse.flow", + compute="_compute_applicable_flow_ids", + string="Applicable Flows", + ) + + @api.depends("rule_ids.picking_type_id") + def _compute_applicable_flow_ids(self): + for route in self: + picking_types = route.rule_ids.picking_type_id + route.applicable_flow_ids = self.env["stock.warehouse.flow"].search( + [ + ("from_picking_type_id", "in", picking_types.ids), + ] + ) diff --git a/stock_warehouse_flow/models/stock_warehouse.py b/stock_warehouse_flow/models/stock_warehouse.py new file mode 100644 index 0000000000..5b58365aac --- /dev/null +++ b/stock_warehouse_flow/models/stock_warehouse.py @@ -0,0 +1,23 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import fields, models + + +class StockWarehouse(models.Model): + _inherit = "stock.warehouse" + + flow_ids = fields.One2many( + comodel_name="stock.warehouse.flow", + inverse_name="warehouse_id", + string="Flows", + ) + + def action_view_all_flows(self): + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "stock_warehouse_flow.stock_warehouse_flow_action" + ) + action["domain"] = [("id", "in", self.flow_ids.ids)] + action["context"] = {"default_warehouse_id": self.id} + return action diff --git a/stock_warehouse_flow/models/stock_warehouse_flow.py b/stock_warehouse_flow/models/stock_warehouse_flow.py new file mode 100644 index 0000000000..e7932464c1 --- /dev/null +++ b/stock_warehouse_flow/models/stock_warehouse_flow.py @@ -0,0 +1,545 @@ +# Copyright 2022 Camptocamp SA +# Copyright 2023 Michael Tietz (MT Software) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError +from odoo.osv import expression +from odoo.tools import float_compare +from odoo.tools.safe_eval import safe_eval + +logger = logging.getLogger(__name__) + + +class ForceRollback(Exception): + pass + + +class StockWarehouseFlow(models.Model): + _name = "stock.warehouse.flow" + _description = "Stock Warehouse Routing Flow" + _order = "sequence" + + name = fields.Char(required=True) + active = fields.Boolean(default=True) + sequence = fields.Integer(default=10) + company_id = fields.Many2one(related="warehouse_id.company_id") + warehouse_id = fields.Many2one( + comodel_name="stock.warehouse", + ondelete="restrict", + string="Warehouse", + default=lambda o: o._default_warehouse_id(), + required=True, + ) + from_picking_type_id = fields.Many2one( + comodel_name="stock.picking.type", + ondelete="restrict", + string="From operation type", + required=True, + index=True, + domain="[('code', '=', 'outgoing')]", + ) + from_location_src_id = fields.Many2one( + comodel_name="stock.location", + compute="_compute_from_location_src_id", + ) + from_location_dest_id = fields.Many2one( + comodel_name="stock.location", + compute="_compute_from_location_dest_id", + ) + to_picking_type_id = fields.Many2one( + comodel_name="stock.picking.type", + ondelete="restrict", + string="To operation type", + readonly=True, + domain="[('default_location_dest_id', '=', from_location_dest_id)]", + check_company=True, + ) + to_output_stock_loc_id = fields.Many2one( + comodel_name="stock.location", + ondelete="restrict", + string="To output location", + check_company=True, + ) + carrier_ids = fields.Many2many( + comodel_name="delivery.carrier", + relation="delivery_carrier_stock_warehouse_flow_rel", + column1="stock_warehouse_flow_id", + column2="delivery_carrier_id", + string="With carriers", + copy=False, + ) + move_domain = fields.Char( + string="Source Routing Domain", + default=[], + copy=False, + help="Domain based on Stock Moves, to define if the " + "routing flow is applicable or not.", + ) + delivery_steps = fields.Selection( + selection=[ + ("ship_only", "Deliver goods directly (1 step)"), + ("pick_ship", "Send goods in output and then deliver (2 steps)"), + ( + "pick_pack_ship", + "Pack goods, send goods in output and then deliver (3 steps)", + ), + ], + string="Outgoing Shipments", + default="ship_only", + required=True, + help="Outgoing route to follow", + ) + sequence_prefix = fields.Char( + help=( + "Used to generate the default prefix of new operation types. " + "(e.g. 'DG' => 'WH/OUT/DG/')" + ) + ) + delivery_route_id = fields.Many2one( + comodel_name="stock.route", + string="Delivery Route", + ondelete="set null", + readonly=True, + index=True, + copy=False, + ) + rule_ids = fields.One2many( + related="delivery_route_id.rule_ids", + string="Rules", + ) + impacted_route_ids = fields.One2many( + comodel_name="stock.route", + compute="_compute_impacted_route_ids", + string="Impacted Routes", + ) + warning = fields.Html(compute="_compute_warning") + pack_stock_loc_id = fields.Many2one( + "stock.location", "Packing Location", check_company=True + ) + output_stock_loc_id = fields.Many2one( + related="to_output_stock_loc_id", + string="Output Location", + readonly=True, + help=( + "Same than 'To output location' field, displayed here to get a " + "global overview of the flow configuration." + ), + ) + pick_type_id = fields.Many2one( + "stock.picking.type", "Pick Type", check_company=True + ) + pack_type_id = fields.Many2one( + "stock.picking.type", "Pack Type", check_company=True + ) + out_type_id = fields.Many2one( + related="to_picking_type_id", + string="Out Type", + readonly=True, + help=( + "Same than 'To operation type' field, displayed here to get a " + "global overview of the flow configuration." + ), + ) + qty = fields.Float( + default=0, + help="If a qty is set the flow can be applied on moves " + "where the move's qty >= the qty set on the flow\n", + ) + uom_id = fields.Many2one( + "uom.uom", "Uom", default=lambda self: self.env.ref("uom.product_uom_unit") + ) + split_method = fields.Selection( + [("simple", "Simple")], + "Split method", + help="Simple => move will be split by the qty of the flow or a multiple of it", + ) + + def _default_warehouse_id(self): + warehouse = self.env["stock.warehouse"].search([]) + if len(warehouse) == 1: + return warehouse + + @api.depends("from_picking_type_id.default_location_src_id") + def _compute_from_location_src_id(self): + for record in self: + location = record.from_picking_type_id.default_location_src_id + if not location: + __, location = self.env["stock.warehouse"]._get_partner_locations() + record.from_location_src_id = location + + @api.depends("from_picking_type_id.default_location_dest_id") + def _compute_from_location_dest_id(self): + for record in self: + location = record.from_picking_type_id.default_location_dest_id + if not location: + location, __ = self.env["stock.warehouse"]._get_partner_locations() + record.from_location_dest_id = location + + def _compute_impacted_route_ids(self): + for flow in self: + rules = self.env["stock.rule"].search( + [ + ("picking_type_id", "=", flow.from_picking_type_id.id), + ("route_id.flow_id", "=", False), + ] + ) + flow.impacted_route_ids = rules.route_id + + def _compute_warning(self): + for flow in self: + flow.warning = False + try: + flow._get_rule_from_delivery_route(html_exc=True) + except UserError as exc: + flow.warning = str(exc) + + def _are_apply_conditions_equal(self, flow): + self.ensure_one() + flow.ensure_one() + if ( + ( + self.carrier_ids & flow.carrier_ids + or not self.carrier_ids + and not flow.carrier_ids + ) + and self.move_domain == flow.move_domain + and self.qty == flow.qty + and self.uom_id == flow.uom_id + and self.split_method == flow.split_method + ): + return True + return False + + @api.constrains( + "warehouse_id", + "from_picking_type_id", + "carrier_ids", + "move_domain", + "qty", + "uom_id", + "split_method", + ) + def _constrains_uniq(self): + for record in self: + args = [ + ("warehouse_id", "=", record.warehouse_id.id), + ("from_picking_type_id", "=", record.from_picking_type_id.id), + ] + flows = record.search(args) - record + for flow in flows: + if record._are_apply_conditions_equal(flow): + raise UserError( + _("Existing flow '%s' already applies on these kind of moves.") + % flow.name + ) + + @api.constrains("qty", "uom_id") + def _constrains_qty_uom(self): + for record in self: + if record.qty and not record.uom_id: + raise UserError( + _("Please set the uom field in addition to the qty field") + ) + + @api.onchange("name") + def onchange_name(self): + self.sequence_prefix = "".join( + [w[0] for w in (self.name or "").split()] + ).upper() + + @api.onchange("warehouse_id") + def onchange_warehouse_id(self): + self.from_picking_type_id = self.warehouse_id.out_type_id + + def _generate_delivery_route(self): + self.ensure_one() + if self.delivery_route_id: + return False + # Re-generate the WH configuration with the chosen delivery steps in a + # savepoint to get a whole route+rules+picking types configured almost + # automatically (to not re-invent the wheel). + # Delivery route values are then copied to generate a new one. + # Copy some picking types with new sublocations first so the new + # delivery route will use them. + if not self.to_picking_type_id: + self.to_picking_type_id = self.warehouse_id.out_type_id.copy( + { + "name": ( + f"{self.warehouse_id.out_type_id.name} " + f"{self.sequence_prefix}" + ), + "active": True, + } + ) + self.to_picking_type_id.sequence_id.prefix += f"{self.sequence_prefix}/" + if "pick_" in self.delivery_steps: + if not self.pick_type_id: + self.pick_type_id = self.warehouse_id.pick_type_id.copy( + { + "name": ( + f"{self.warehouse_id.pick_type_id.name} " + f"{self.sequence_prefix}" + ), + "active": True, + } + ) + self.pick_type_id.sequence_id.prefix += f"{self.sequence_prefix}/" + # Create a dedicated 'Output/SUB' location + if not self.to_output_stock_loc_id: + self.to_output_stock_loc_id = ( + self.warehouse_id.wh_output_stock_loc_id.copy( + { + "name": self.sequence_prefix, + "location_id": self.warehouse_id.wh_output_stock_loc_id.id, + } + ) + ) + self.pick_type_id.default_location_dest_id = self.to_output_stock_loc_id + self.to_picking_type_id.default_location_src_id = ( + self.to_output_stock_loc_id + ) + if "pack_" in self.delivery_steps: + if not self.pack_type_id: + self.pack_type_id = self.warehouse_id.pack_type_id.copy( + { + "name": ( + f"{self.warehouse_id.pack_type_id.name} " + f"{self.sequence_prefix}" + ), + "active": True, + } + ) + self.pack_type_id.sequence_id.prefix += f"{self.sequence_prefix}/" + # Create a dedicated 'Packing Zone/SUB' location + if not self.pack_stock_loc_id: + self.pack_stock_loc_id = self.warehouse_id.wh_pack_stock_loc_id.copy( + { + "name": self.sequence_prefix, + "location_id": self.warehouse_id.wh_pack_stock_loc_id.id, + } + ) + self.pick_type_id.default_location_dest_id = self.pack_stock_loc_id + self.pack_type_id.default_location_src_id = self.pack_stock_loc_id + self.pack_type_id.default_location_dest_id = self.to_output_stock_loc_id + try: + with self.env.cr.savepoint(): + wh_vals = { + "delivery_steps": self.delivery_steps, + "out_type_id": self.to_picking_type_id.id, + } + if self.pick_type_id: + wh_vals.update( + { + "pick_type_id": self.pick_type_id.id, + "wh_output_stock_loc_id": self.to_output_stock_loc_id.id, + } + ) + if self.pack_type_id: + wh_vals.update( + { + "pack_type_id": self.pack_type_id.id, + "wh_pack_stock_loc_id": self.pack_stock_loc_id.id, + } + ) + wh_vals["out_type_id"] = self.to_picking_type_id.id + self.warehouse_id.with_context(do_not_check_quant=True).write(wh_vals) + vals = self.warehouse_id.delivery_route_id.copy_data()[0] + raise ForceRollback() + + except ForceRollback: + logger.info( + f"Routing flow {self.name}: delivery route data generated " + f"from WH {self.warehouse_id.name}" + ) + + vals.update( + { + "name": f"{self.warehouse_id.name}: {self.name}", + "active": True, + "flow_id": self.id, + # Give the priority to routes/rules not tied to a flow + # when the initial move is created by the procurement + "sequence": 1000, + } + ) + self.delivery_route_id = self.env["stock.route"].create(vals) + # Subscribe the route to the warehouse + if self.delivery_route_id.warehouse_selectable: + self.warehouse_id.route_ids |= self.delivery_route_id + + def action_generate_route(self): + for flow in self: + flow._generate_delivery_route() + return True + + def _search_for_move_domain(self, move): + domain = [ + ("from_picking_type_id", "=", move.picking_type_id.id), + ("delivery_route_id", "!=", False), + ] + if move.group_id.carrier_id: + domain.append(("carrier_ids", "in", move.group_id.carrier_id.ids)) + else: + domain.append(("carrier_ids", "=", False)) + qty_uom_domain = expression.OR( + [ + [("qty", "=", False)], + [ + ("uom_id.category_id", "=", move.product_uom_category_id.id), + ], + ] + ) + return expression.AND([domain, qty_uom_domain]) + + def _is_domain_valid_for_move(self, move): + if not self.move_domain: + return move + domain = safe_eval(self.move_domain or "[]") + if not domain: + return move + return move.filtered_domain(domain) + + def _is_qty_valid_for_move(self, move): + if not self.qty: + return move + if self.uom_id.category_id != move.product_uom_category_id: + return move.browse() + qty = self.uom_id._compute_quantity(self.qty, move.product_id.uom_id) + if qty <= move.product_qty: + return move + return move.browse() + + def _is_valid_for_move(self, move): + self.ensure_one() + move = self._is_domain_valid_for_move(move) + if not move: + return move + return self._is_qty_valid_for_move(move) + + @api.model + def _search_for_move(self, move): + """Return matching flows for given move""" + domain = self._search_for_move_domain(move) + return self.search(domain) + + @api.model + def _search_and_apply_for_move(self, move): + move.ensure_one() + flows = self._search_for_move(move) + if not flows: + return move + return flows.apply_on_move(move) + + def apply_on_move(self, move): + move_ids = [] + flows = self + for flow in self: + if not flow._is_valid_for_move(move): + continue + move_ids.append(move.id) + split_moves = flow.split_move(move) + # Try to apply the rest of the flows to the split move + for split_move in split_moves: + move_ids += (flows - flow).apply_on_move(split_move).ids + flow._apply_on_move(move) + return move.browse(move_ids) + raise UserError( + _( + "No routing flow available for the move {move} in transfer {picking}." + ).format(move=move.display_name, picking=move.picking_id.name) + ) + + def _get_rule_from_delivery_route(self, html_exc=False): + rule = self.delivery_route_id.rule_ids.filtered( + lambda r: r.picking_type_id == self.to_picking_type_id + ) + if self.delivery_route_id and not rule: + args = { + "picking_type": self.to_picking_type_id.display_name, + "delivery_route": self.delivery_route_id.display_name, + } + if html_exc: + args = { + "picking_type": f"{self.to_picking_type_id.display_name}", + "delivery_route": f"{self.delivery_route_id.display_name}", + } + raise UserError( + _( + "No rule corresponding to '%(picking_type)s' operation type " + "has been found on delivery route '%(delivery_route)s'.\n" + "Please check your configuration." + ) + % args + ) + return rule + + def _prepare_move_split_vals(self, move, split_qty): + split_qty_uom = move.product_id.uom_id._compute_quantity( + split_qty, move.product_uom, rounding_method="HALF-UP" + ) + return move._prepare_move_split_vals(split_qty_uom) + + def _split_move(self, move, split_qty): + split_move = move.copy(self._prepare_move_split_vals(move, split_qty)) + new_product_qty = move.product_id.uom_id._compute_quantity( + move.product_qty - split_qty, move.product_uom, round=False + ) + move.product_uom_qty = new_product_qty + return split_move + + def _get_split_qty_multiple_of(self, move, qty, uom=None): + """Returns the qty to split + split qty = move.product_qty - (a multiple of the given qty)""" + product_uom = move.product_id.uom_id + if uom: + qty = uom._compute_quantity(qty, product_uom) + rounding = product_uom.rounding + if float_compare(qty, move.product_qty, precision_rounding=rounding) > 0: + return + multiple_qty = int(move.product_qty / qty) * qty + split_qty = move.product_qty - multiple_qty + # There is nothing to split if the split_qty is 0 + # then the move qty is the same or a multiple of the qty + if float_compare(split_qty, 0, precision_rounding=rounding) <= 0: + return + return split_qty + + def _split_move_simple(self, move): + split_moves = move.browse([]) + split_qty = self._get_split_qty_multiple_of(move, self.qty, self.uom_id) + if not split_qty: + return split_moves + return self._split_move(move, split_qty) + + def split_move(self, move): + self.ensure_one() + split_moves = move.browse([]) + if self.split_method == "simple": + return self._split_move_simple(move) + return split_moves + + def _apply_on_move(self, move): + """Apply the flow configuration on the move.""" + if not self: + return False + logger.info("Applying flow '%s' on '%s'", self.name, move) + rule = self._get_rule_from_delivery_route() + move.picking_id = False + move.picking_type_id = self.to_picking_type_id + move.location_id = ( + self.to_output_stock_loc_id + or self.to_picking_type_id.default_location_src_id + ) + move.procure_method = rule.procure_method + move.rule_id = rule + move._assign_picking() + + def write(self, vals): + res = super().write(vals) + # Sync 'active' field with underlying route + if "active" in vals: + self.delivery_route_id.write({"active": vals["active"]}) + return res diff --git a/stock_warehouse_flow/readme/CONFIGURE.rst b/stock_warehouse_flow/readme/CONFIGURE.rst new file mode 100644 index 0000000000..e809246565 --- /dev/null +++ b/stock_warehouse_flow/readme/CONFIGURE.rst @@ -0,0 +1,18 @@ +Got to "Inventory > Settings > Routing Flows". + +A routing flow can be seen as a helper to generate a delivery route (like the +warehouse is doing automatically). The new route will get its own rules and +operation types that doesn't overlap with the default ones of the warehouse. + +A routing flow is responsible to change the warehouse delivery route of a move +by another one depending on some criterias: + +* the initial outgoing operation type (usually the default one) +* the carrier +* a custom domain (applied on the move) + +This way you are able to change the route a move will take depending on its +carrier and, for instance, the type or the packaging of the product +you want to ship. + +.. image:: https://raw.githubusercontent.com/OCA/wms/14.0/stock_warehouse_flow/static/description/config.png diff --git a/stock_warehouse_flow/readme/CONTRIBUTORS.rst b/stock_warehouse_flow/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..a2b6be2e2e --- /dev/null +++ b/stock_warehouse_flow/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* Sébastien Alix +* Jacques-Etienne Baudoux +* Michael Tietz (MT Software) diff --git a/stock_warehouse_flow/readme/DESCRIPTION.rst b/stock_warehouse_flow/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..95ad69302b --- /dev/null +++ b/stock_warehouse_flow/readme/DESCRIPTION.rst @@ -0,0 +1,18 @@ +This module introduces the concept of routing flows in order to manage +different delivery routes for a warehouse. + +The default behavior of Odoo allows you to have only one delivery route per +warehouse (with one, two or three steps). +With this module, you are now able to manage multiple delivery routes (having +their own rules and operation types), the right one being selected automatically +based on some criterias, like the carrier and any attribute of the stock move +to process. + +This allows you to define a delivery route based on the type of goods to ship, +for instance: + +* whole pallet (pick + ship) +* cold chain goods +* dangerous goods + +.. image:: https://raw.githubusercontent.com/OCA/wms/14.0/stock_warehouse_flow/static/description/flow.png diff --git a/stock_warehouse_flow/readme/ROADMAP.rst b/stock_warehouse_flow/readme/ROADMAP.rst new file mode 100644 index 0000000000..6fcec940b9 --- /dev/null +++ b/stock_warehouse_flow/readme/ROADMAP.rst @@ -0,0 +1,2 @@ +Currently, the module supports only delivery routes, but it could improved to +support reception routes as well. diff --git a/stock_warehouse_flow/readme/USAGE.rst b/stock_warehouse_flow/readme/USAGE.rst new file mode 100644 index 0000000000..cd73d443fd --- /dev/null +++ b/stock_warehouse_flow/readme/USAGE.rst @@ -0,0 +1,2 @@ +When a stock move is confirmed, if a flow is matching all the criteria then +the new delivery route will be automatically applied. diff --git a/stock_warehouse_flow/security/ir.model.access.csv b/stock_warehouse_flow/security/ir.model.access.csv new file mode 100644 index 0000000000..4061d3a0a4 --- /dev/null +++ b/stock_warehouse_flow/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_stock_warehouse_flow_all,stock.warehouse.flow all users,model_stock_warehouse_flow,base.group_user,1,0,0,0 +access_stock_warehouse_flow_user,stock.warehouse.flow user,model_stock_warehouse_flow,stock.group_stock_user,1,0,0,0 +access_stock_warehouse_flow_manager,stock.warehouse.flow manager,model_stock_warehouse_flow,stock.group_stock_manager,1,1,1,1 diff --git a/stock_warehouse_flow/static/description/config.png b/stock_warehouse_flow/static/description/config.png new file mode 100644 index 0000000000..3f87ffc4fb Binary files /dev/null and b/stock_warehouse_flow/static/description/config.png differ diff --git a/stock_warehouse_flow/static/description/flow.png b/stock_warehouse_flow/static/description/flow.png new file mode 100644 index 0000000000..05f4d83bc0 Binary files /dev/null and b/stock_warehouse_flow/static/description/flow.png differ diff --git a/stock_warehouse_flow/static/description/icon.png b/stock_warehouse_flow/static/description/icon.png new file mode 100644 index 0000000000..6feb17ab71 Binary files /dev/null and b/stock_warehouse_flow/static/description/icon.png differ diff --git a/stock_warehouse_flow/static/description/index.html b/stock_warehouse_flow/static/description/index.html new file mode 100644 index 0000000000..5c08426744 --- /dev/null +++ b/stock_warehouse_flow/static/description/index.html @@ -0,0 +1,474 @@ + + + + + + +Stock Warehouse Flow + + + +
+

Stock Warehouse Flow

+ + +

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

+

This module introduces the concept of routing flows in order to manage +different delivery routes for a warehouse.

+

The default behavior of Odoo allows you to have only one delivery route per +warehouse (with one, two or three steps). +With this module, you are now able to manage multiple delivery routes (having +their own rules and operation types), the right one being selected automatically +based on some criterias, like the carrier and any attribute of the stock move +to process.

+

This allows you to define a delivery route based on the type of goods to ship, +for instance:

+
    +
  • whole pallet (pick + ship)
  • +
  • cold chain goods
  • +
  • dangerous goods
  • +
+https://raw.githubusercontent.com/OCA/wms/14.0/stock_warehouse_flow/static/description/flow.png +
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

Configuration

+

Got to “Inventory > Settings > Routing Flows”.

+

A routing flow can be seen as a helper to generate a delivery route (like the +warehouse is doing automatically). The new route will get its own rules and +operation types that doesn’t overlap with the default ones of the warehouse.

+

A routing flow is responsible to change the warehouse delivery route of a move +by another one depending on some criterias:

+
    +
  • the initial outgoing operation type (usually the default one)
  • +
  • the carrier
  • +
  • a custom domain (applied on the move)
  • +
+

This way you are able to change the route a move will take depending on its +carrier and, for instance, the type or the packaging of the product +you want to ship.

+https://raw.githubusercontent.com/OCA/wms/14.0/stock_warehouse_flow/static/description/config.png +
+
+

Usage

+

When a stock move is confirmed, if a flow is matching all the criteria then +the new delivery route will be automatically applied.

+
+
+

Known issues / Roadmap

+

Currently, the module supports only delivery routes, but it could improved to +support reception routes as well.

+
+
+

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
  • +
  • BCIM
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

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

+

This module is part of the OCA/wms project on GitHub.

+

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

+
+
+
+ + diff --git a/stock_warehouse_flow/tests/__init__.py b/stock_warehouse_flow/tests/__init__.py new file mode 100644 index 0000000000..e37a702088 --- /dev/null +++ b/stock_warehouse_flow/tests/__init__.py @@ -0,0 +1,3 @@ +from . import test_delivery_carrier +from . import test_warehouse +from . import test_warehouse_flow diff --git a/stock_warehouse_flow/tests/common.py b/stock_warehouse_flow/tests/common.py new file mode 100644 index 0000000000..a804529ae8 --- /dev/null +++ b/stock_warehouse_flow/tests/common.py @@ -0,0 +1,92 @@ +# Copyright 2022 Camptocamp SA +# Copyright 2023 Michael Tietz (MT Software) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import fields +from odoo.tests.common import TransactionCase + + +class CommonFlow(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + ref = cls.env.ref + cls.wh = ref("stock.warehouse0") + cls.company = cls.wh.company_id + cls.loc_stock = cls.wh.lot_stock_id + cls.loc_customer = cls.env.ref("stock.stock_location_customers") + cls.product = ref("product.product_product_9") + cls._update_qty_in_location(cls.loc_stock, cls.product, 10) + cls.env["stock.warehouse.flow"].search([]).action_generate_route() + + def _get_flow(self, delivery_steps): + return self.env.ref( + f"stock_warehouse_flow.stock_warehouse_flow_delivery_{delivery_steps}" + ) + + @classmethod + def _update_qty_in_location( + cls, location, product, quantity, package=None, lot=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 + ) + + def _run_procurement(self, product, qty, carrier=None): + proc_group = self.env["procurement.group"] + uom = product.uom_id + proc_qty, proc_uom = uom._adjust_uom_quantities(qty, uom) + today = fields.Date.today() + proc_group = self.env["procurement.group"].create( + {"carrier_id": carrier.id if carrier else False} + ) + values = { + "group_id": proc_group, + "date_planned": today, + "date_deadline": today, + "warehouse_id": self.wh or False, + "company_id": self.company, + } + procurement = proc_group.Procurement( + product, + proc_qty, + proc_uom, + self.loc_customer, + product.name, + "PROC TEST", + self.company, + values, + ) + proc_group.run([procurement]) + + def _validate_picking(self, picking): + for move_line in picking.move_line_ids: + move_line.qty_done = move_line.reserved_uom_qty + picking._action_done() + + def _prepare_split_test(self, qty=None): + ship_flow = self._get_flow("ship_only") + ship_flow.sequence = 100 + pick_flow = self._get_flow("pick_ship") + pick_flow.write( + { + "sequence": 1, + "split_method": "simple", + "qty": qty or 2, + "carrier_ids": [(6, 0, ship_flow.carrier_ids.ids)], + } + ) + return ship_flow, pick_flow + + def _run_split_flow(self, qty=None): + pick_flow = self._get_flow("pick_ship") + moves_before = self.env["stock.move"].search([]) + self._run_procurement(self.product, qty or 5, pick_flow.carrier_ids) + moves_after = self.env["stock.move"].search([]) + return (moves_after - moves_before).sorted(lambda m: m.id) diff --git a/stock_warehouse_flow/tests/test_delivery_carrier.py b/stock_warehouse_flow/tests/test_delivery_carrier.py new file mode 100644 index 0000000000..287e49b99f --- /dev/null +++ b/stock_warehouse_flow/tests/test_delivery_carrier.py @@ -0,0 +1,12 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + + +from . import common + + +class TestDeliveryCarrier(common.CommonFlow): + def test_action_view_flows(self): + flow = self._get_flow("pick_ship") + action = flow.carrier_ids.action_view_flows() + self.assertEqual(action["domain"][0][2], flow.ids) diff --git a/stock_warehouse_flow/tests/test_warehouse.py b/stock_warehouse_flow/tests/test_warehouse.py new file mode 100644 index 0000000000..1a4b0c4743 --- /dev/null +++ b/stock_warehouse_flow/tests/test_warehouse.py @@ -0,0 +1,12 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + + +from . import common + + +class TestWarehouse(common.CommonFlow): + def test_action_view_all_flows(self): + action = self.wh.action_view_all_flows() + self.assertTrue(self.wh.flow_ids) + self.assertEqual(action["domain"][0][2], self.wh.flow_ids.ids) diff --git a/stock_warehouse_flow/tests/test_warehouse_flow.py b/stock_warehouse_flow/tests/test_warehouse_flow.py new file mode 100644 index 0000000000..3a446e2fdd --- /dev/null +++ b/stock_warehouse_flow/tests/test_warehouse_flow.py @@ -0,0 +1,134 @@ +# Copyright 2022 Camptocamp SA +# Copyright 2023 Michael Tietz (MT Software) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo.exceptions import UserError + +from . import common + + +class TestWarehouseFlow(common.CommonFlow): + def test_flow_ship_only(self): + """Replace the initial move by a 'ship_only' move.""" + # NOTE: use the recorder when migrating to 15.0 to catch created moves + moves_before = self.env["stock.move"].search([]) + flow = self._get_flow("ship_only") + self._run_procurement(self.product, 10, flow.carrier_ids) + moves_after = self.env["stock.move"].search([]) + moves = moves_after - moves_before + # Check we got pick+ship moves instead of one ship_only move + move_ship = moves.filtered(lambda m: m.picking_type_id.code == "outgoing") + to_picking_type = flow.to_picking_type_id + self.assertRecordValues( + move_ship, + [ + { + "picking_type_id": to_picking_type.id, + "location_id": to_picking_type.default_location_src_id.id, + "location_dest_id": self.loc_customer.id, + }, + ], + ) + self.assertIn(flow.sequence_prefix, move_ship.picking_id.name) + move_ship.picking_id.action_assign() + self.assertEqual(move_ship.state, "assigned") + self._validate_picking(move_ship.picking_id) + + def test_flow_pick_ship(self): + """Replace the initial move by pick+ship chained moves.""" + # NOTE: use the recorder when migrating to 15.0 to catch created moves + moves_before = self.env["stock.move"].search([]) + flow = self._get_flow("pick_ship") + self._run_procurement(self.product, 10, flow.carrier_ids) + moves_after = self.env["stock.move"].search([]) + moves = moves_after - moves_before + # Check we got pick+ship moves instead of one ship_only move + move_ship = moves.filtered(lambda m: m.picking_type_id.code == "outgoing") + to_picking_type = flow.to_picking_type_id + self.assertRecordValues( + move_ship, + [ + { + "picking_type_id": to_picking_type.id, + "location_id": to_picking_type.default_location_src_id.id, + "location_dest_id": self.loc_customer.id, + }, + ], + ) + self.assertIn(flow.sequence_prefix, move_ship.picking_id.name) + move_pick = move_ship.move_orig_ids + self.assertTrue(move_pick) + move_pick.picking_id.action_assign() + self.assertEqual(move_pick.state, "assigned") + self._validate_picking(move_pick.picking_id) + self.assertEqual(move_pick.state, "done") + self.assertEqual(move_ship.state, "assigned") + self._validate_picking(move_ship.picking_id) + + def test_no_rule_found_on_delivery_route(self): + flow = self._get_flow("pick_ship") + # Remove the rule + self.assertFalse(flow.warning) + rule = flow._get_rule_from_delivery_route() + rule.unlink() + # Check the warning message + self.assertTrue(flow.warning) + # Check that an error is raised when processing the move + exception_msg = ( + "No rule corresponding to .*%s.* operation type " + "has been found on delivery route .*%s.*.\n" + "Please check your configuration." + ) % ( + flow.to_picking_type_id.display_name, + flow.delivery_route_id.display_name, + ) + with self.assertRaisesRegex(UserError, exception_msg): + self._run_procurement(self.product, 10, flow.carrier_ids) + + def test_no_valid_flow_for_move(self): + flow = self._get_flow("ship_only") + flow.move_domain = "[('state', '=', 'unknown')]" + message = "^No routing flow available for the move" + with self.assertRaisesRegex(UserError, message): + self._run_procurement(self.product, 10, flow.carrier_ids) + + def test_flow_uniq_constraint(self): + flow = self._get_flow("pick_ship") + vals = flow.copy_data()[0] + with self.assertRaises(UserError): + vals["carrier_ids"] = [ + (6, 0, self.env.ref("delivery.delivery_carrier").ids) + ] + self.env["stock.warehouse.flow"].create(vals) + + def test_flow_qty_uom_constraint(self): + flow = self._get_flow("pick_ship") + flow.write({"qty": 0, "uom_id": False}) + with self.assertRaises(UserError): + flow.write({"qty": 2}) + + def test_split(self): + self._prepare_split_test() + moves = self._run_split_flow() + self.assertEqual(moves.mapped("product_qty"), [4, 1, 4]) + self.assertEqual( + moves.mapped("picking_type_id.code"), ["outgoing", "outgoing", "internal"] + ) + + def test_nothing_to_split(self): + self._prepare_split_test() + moves = self._run_split_flow(4) + self.assertEqual(moves.mapped("product_qty"), [4, 4]) + self.assertEqual(moves.mapped("picking_type_id.code"), ["outgoing", "internal"]) + + def test_split_no_qty_match(self): + ship_flow, pick_flow = self._prepare_split_test(5) + ship_flow.unlink() + with self.assertRaises(UserError): + self._run_split_flow(4) + + def test_split_no_flow_for_split_move(self): + ship_flow, pick_flow = self._prepare_split_test() + ship_flow.unlink() + with self.assertRaises(UserError): + self._run_split_flow() diff --git a/stock_warehouse_flow/views/delivery_carrier.xml b/stock_warehouse_flow/views/delivery_carrier.xml new file mode 100644 index 0000000000..03d07955e5 --- /dev/null +++ b/stock_warehouse_flow/views/delivery_carrier.xml @@ -0,0 +1,25 @@ + + + + + + delivery.carrier.form.inherit + delivery.carrier + + +
+ +
+
+
+ +
diff --git a/stock_warehouse_flow/views/stock_route.xml b/stock_warehouse_flow/views/stock_route.xml new file mode 100644 index 0000000000..f2802dc786 --- /dev/null +++ b/stock_warehouse_flow/views/stock_route.xml @@ -0,0 +1,17 @@ + + + + + + stock.location.route.tree.inherit + stock.route + + + + + + + + + diff --git a/stock_warehouse_flow/views/stock_warehouse.xml b/stock_warehouse_flow/views/stock_warehouse.xml new file mode 100644 index 0000000000..acf6d6009a --- /dev/null +++ b/stock_warehouse_flow/views/stock_warehouse.xml @@ -0,0 +1,23 @@ + + + + + + stock.warehouse.form.inherit + stock.warehouse + + +
+
+
+
+ +
diff --git a/stock_warehouse_flow/views/stock_warehouse_flow.xml b/stock_warehouse_flow/views/stock_warehouse_flow.xml new file mode 100644 index 0000000000..d54d53efc8 --- /dev/null +++ b/stock_warehouse_flow/views/stock_warehouse_flow.xml @@ -0,0 +1,242 @@ + + + + + + stock.warehouse.flow.form + stock.warehouse.flow + +
+ + +
+
+ + + + + + + + + + + + + +
+ => + + to + +
+ + + +
+ + + + + + + +