From 37f108d55f78cb0608323f6638f5bd6c111be9f7 Mon Sep 17 00:00:00 2001 From: FrankC013 Date: Mon, 6 Nov 2023 11:45:50 +0100 Subject: [PATCH 01/31] [ADD] stock_location_flowable: new module --- stock_location_flowable/README.rst | 62 +++ stock_location_flowable/__init__.py | 1 + stock_location_flowable/__manifest__.py | 21 + stock_location_flowable/i18n/es.po | 372 ++++++++++++++++ stock_location_flowable/models/__init__.py | 8 + .../models/mrp_production.py | 52 +++ .../models/stock_location.py | 227 ++++++++++ stock_location_flowable/models/stock_move.py | 38 ++ .../models/stock_move_line.py | 43 ++ .../models/stock_picking.py | 204 +++++++++ .../models/stock_picking_type.py | 35 ++ stock_location_flowable/models/stock_quant.py | 36 ++ .../models/stock_return_picking.py | 26 ++ .../readme/CONTRIBUTORS.rst | 3 + .../readme/DESCRIPTION.rst | 1 + .../static/description/icon.png | Bin 0 -> 6342 bytes .../static/description/index.html | 421 ++++++++++++++++++ stock_location_flowable/tests/__init__.py | 5 + stock_location_flowable/tests/test_common.py | 200 +++++++++ .../tests/test_mrp_production.py | 43 ++ .../tests/test_stock_location.py | 341 ++++++++++++++ .../tests/test_stock_picking.py | 337 ++++++++++++++ .../tests/test_stock_picking_type.py | 36 ++ .../views/mrp_production_views.xml | 20 + .../views/stock_location_views.xml | 116 +++++ .../views/stock_move_views.xml | 20 + .../views/stock_picking_type_views.xml | 17 + .../views/stock_picking_views.xml | 68 +++ 28 files changed, 2753 insertions(+) create mode 100644 stock_location_flowable/README.rst create mode 100644 stock_location_flowable/__init__.py create mode 100644 stock_location_flowable/__manifest__.py create mode 100644 stock_location_flowable/i18n/es.po create mode 100644 stock_location_flowable/models/__init__.py create mode 100644 stock_location_flowable/models/mrp_production.py create mode 100644 stock_location_flowable/models/stock_location.py create mode 100644 stock_location_flowable/models/stock_move.py create mode 100644 stock_location_flowable/models/stock_move_line.py create mode 100644 stock_location_flowable/models/stock_picking.py create mode 100644 stock_location_flowable/models/stock_picking_type.py create mode 100644 stock_location_flowable/models/stock_quant.py create mode 100644 stock_location_flowable/models/stock_return_picking.py create mode 100644 stock_location_flowable/readme/CONTRIBUTORS.rst create mode 100644 stock_location_flowable/readme/DESCRIPTION.rst create mode 100644 stock_location_flowable/static/description/icon.png create mode 100644 stock_location_flowable/static/description/index.html create mode 100644 stock_location_flowable/tests/__init__.py create mode 100644 stock_location_flowable/tests/test_common.py create mode 100644 stock_location_flowable/tests/test_mrp_production.py create mode 100644 stock_location_flowable/tests/test_stock_location.py create mode 100644 stock_location_flowable/tests/test_stock_picking.py create mode 100644 stock_location_flowable/tests/test_stock_picking_type.py create mode 100644 stock_location_flowable/views/mrp_production_views.xml create mode 100644 stock_location_flowable/views/stock_location_views.xml create mode 100644 stock_location_flowable/views/stock_move_views.xml create mode 100644 stock_location_flowable/views/stock_picking_type_views.xml create mode 100644 stock_location_flowable/views/stock_picking_views.xml diff --git a/stock_location_flowable/README.rst b/stock_location_flowable/README.rst new file mode 100644 index 000000000..3c111884c --- /dev/null +++ b/stock_location_flowable/README.rst @@ -0,0 +1,62 @@ +======================= +Stock Location Flowable +======================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:53b089e0074153d414bd76790674ba0bd683e87a8d33f0ed62a664e34a0dfded + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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-NuoBiT%2Fodoo--addons-lightgray.png?logo=github + :target: https://github.com/NuoBiT/odoo-addons/tree/14.0/stock_location_flowable + :alt: NuoBiT/odoo-addons + +|badge1| |badge2| |badge3| + +* Customizations that allow organizing, controlling, and mixing bulk liquid and solid products in a location. + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* NuoBiT Solutions + +Contributors +~~~~~~~~~~~~ + +* `NuoBiT `_: + + * Frank Cespedes + +Maintainers +~~~~~~~~~~~ + +This module is part of the `NuoBiT/odoo-addons `_ project on GitHub. + +You are welcome to contribute. diff --git a/stock_location_flowable/__init__.py b/stock_location_flowable/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/stock_location_flowable/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/stock_location_flowable/__manifest__.py b/stock_location_flowable/__manifest__.py new file mode 100644 index 000000000..8c6a0c320 --- /dev/null +++ b/stock_location_flowable/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright NuoBiT Solutions - Frank Cespedes +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +{ + "name": "Stock Location Flowable", + "summary": "Customizations that allow organizing, controlling, and" + " mixing bulk liquid and solid products in a location", + "version": "14.0.1.0.1", + "author": "NuoBiT Solutions", + "website": "https://github.com/nuobit/odoo-addons", + "category": "Stock", + "depends": ["mrp"], + "license": "AGPL-3", + "data": [ + "views/stock_location_views.xml", + "views/stock_picking_views.xml", + "views/stock_picking_type_views.xml", + "views/mrp_production_views.xml", + "views/stock_move_views.xml", + ], +} diff --git a/stock_location_flowable/i18n/es.po b/stock_location_flowable/i18n/es.po new file mode 100644 index 000000000..398a75335 --- /dev/null +++ b/stock_location_flowable/i18n/es.po @@ -0,0 +1,372 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * stock_location_flowable +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-11-27 11:13+0000\n" +"PO-Revision-Date: 2023-11-27 11:13+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "All allowed products must be tracked by lot" +msgstr "Todos los productos permitidos deben ser rastreados por lote." + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_allowed_product_ids +msgid "Allowed Products" +msgstr "Productos Permitidos" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#: model:ir.actions.act_window,name:stock_location_flowable.action_picking_tree_blocked +#: model_terms:ir.ui.view,arch_db:stock_location_flowable.stock_picking_type_kanban +#, python-format +msgid "Blocked" +msgstr "Bloqueado" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_capacity +#: model_terms:ir.ui.view,arch_db:stock_location_flowable.stock_location_view_form +msgid "Capacity" +msgstr "Capacidad" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "Capacity must be greater than 0" +msgstr "La capacidad debe ser mayor que 0." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "Capacity must be greater than capacity occupied %s" +msgstr "La capacidad debe ser mayor que la capacidad ocupada %s" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking_type__count_picking_blocked +msgid "Count Picking Blocked" +msgstr "Conteo de Albarán Bloqueado" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__create_lots +#: model_terms:ir.ui.view,arch_db:stock_location_flowable.stock_location_view_form +msgid "Create Lots" +msgstr "Crear Lotes" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_mrp_production__display_name +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__display_name +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_move__display_name +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_move_line__display_name +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking__display_name +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking_type__display_name +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_return_picking__display_name +msgid "Display Name" +msgstr "Nombre mostrado" + +#. module: stock_location_flowable +#: model_terms:ir.ui.view,arch_db:stock_location_flowable.stock_location_view_form +msgid "Enable" +msgstr "Habilitar" + +#. module: stock_location_flowable +#: model_terms:ir.ui.view,arch_db:stock_location_flowable.stock_location_view_form +msgid "Flowable" +msgstr "Fluido" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_capacity_occupied +msgid "Flowable Capacity Occupied" +msgstr "Capacidad Fluida Ocupada" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "Flowable Location Warning" +msgstr "Advertencia de Ubicación Fluido" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking_type__flowable_operation +msgid "Flowable Operation" +msgstr "Operación Fluido" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_percentage_occupied +msgid "Flowable Percentage Occupied" +msgstr "Porcentaje Fluido Ocupado" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_production_id +msgid "Flowable Production" +msgstr "Producción de Fluido" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_storage +msgid "Flowable Storage" +msgstr "Almacenamiento de Fluido" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_mrp_production__id +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__id +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_move__id +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_move_line__id +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking__id +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking_type__id +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_return_picking__id +msgid "ID" +msgstr "ID" + +#. module: stock_location_flowable +#: model:ir.model,name:stock_location_flowable.model_stock_location +msgid "Inventory Locations" +msgstr "Ubicaciones de inventario" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_blocked_popover +msgid "JSON data for the popover widget" +msgstr "Datos JSON para el widget emergente" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_mrp_production____last_update +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location____last_update +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_move____last_update +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_move_line____last_update +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking____last_update +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking_type____last_update +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_return_picking____last_update +msgid "Last Modified on" +msgstr "Última modificación el" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "Location capacity is full" +msgstr "La capacidad de la ubicación está llena" + +#. module: stock_location_flowable +#: model_terms:ir.ui.view,arch_db:stock_location_flowable.stock_location_view_form +msgid "Manufacturing Order" +msgstr "Orden de producción" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking__flowable_production_ids +#: model_terms:ir.ui.view,arch_db:stock_location_flowable.view_picking_form +#, python-format +msgid "Manufacturing Orders" +msgstr "Ordenes de producción" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "More than one manufacturing code in picking type for flowable location %s" +msgstr "Más de un código de fabricación en el tipo de operación para la ubicación fluida %s" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "Not found sequence in manufacturing picking type %s for flowable location %s" +msgstr "Secuencia no encontrada en el tipo de operación de fabricación %s para la ubicación fluida %s" + +#. module: stock_location_flowable +#: model_terms:ir.actions.act_window,help:stock_location_flowable.action_picking_tree_blocked +msgid "No transfer found. Let's create one!" +msgstr "No se encontró ninguna transferencia. ¡Creemos uno!" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "Not found manufacturing picking type for flowable location %s to do flowable" +" mixing in %s" +msgstr "No se encontró el tipo de operación de producción para la ubicación fluida" +" %s para realizar la mezcla fluida en %s" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking_type.py:0 +#, python-format +msgid "Only one picking type can be flowable in a warehouse %s.." +msgstr "Sólo un tipo de operación puede ser fluido en el almacén %s." + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_mrp_production__picking_id +msgid "Picking" +msgstr "Albarán" + +#. module: stock_location_flowable +#: model:ir.model,name:stock_location_flowable.model_stock_picking_type +msgid "Picking Type" +msgstr "Tipo de albarán" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "The allowed products %s cannot have different Unit of Measure than flowable location %s" +msgstr "Los productos permitidos %s no pueden tener una unidad de medida diferente a la ubicación fluida %s" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "Product %s must be tracked by lot" +msgstr "El producto %s debe ser rastreado por lote" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "Product %s not allowed in flowable location %s" +msgstr "Producto %s no permitido en ubicación fluida %s" + +#. module: stock_location_flowable +#: model:ir.model,name:stock_location_flowable.model_stock_move_line +msgid "Product Moves (Stock Move Line)" +msgstr "Movimientos de Producto (Stock Move Line)" + +#. module: stock_location_flowable +#: model:ir.model,name:stock_location_flowable.model_mrp_production +msgid "Production Order" +msgstr "Orden de producción" + +#. module: stock_location_flowable +#: model:ir.model,name:stock_location_flowable.model_stock_return_picking +msgid "Return Picking" +msgstr "Albarán de devolución" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_sequence_id +msgid "Sequence" +msgstr "Secuencia" + +#. module: stock_location_flowable +#: model:ir.model,name:stock_location_flowable.model_stock_move +msgid "Stock Move" +msgstr "Movimiento de inventario" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_move_line.py:0 +#, python-format +msgid "The location %s is blocked. Probably you need to review the pending " +"manufacturing orders related to this location" +msgstr "La ubicación %s está bloqueada. Probablemente necesites revisar las" +" órdenes de producción pendientes relacionadas con esta ubicación" + +#. module: stock_location_flowable +#: model_terms:ir.ui.view,arch_db:stock_location_flowable.stock_location_view_form +msgid "The location is blocked" +msgstr "La ubicación está bloqueada." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "The product %s is measured in %s. You can only assign products that have the" +" allowed unit of measure" +msgstr "El producto %s se mide en %s. Sólo puedes asignar productos que tengan la" +" unidad de medida permitida" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "This location is blocked because it has a manufacturing order assigned." +msgstr "Esta ubicación está bloqueada porque tiene una orden de producción asignada." + +#. module: stock_location_flowable +#: model:ir.model,name:stock_location_flowable.model_stock_picking +msgid "Transfer" +msgstr "Albarán" + +#. module: stock_location_flowable +#: model_terms:ir.actions.act_window,help:stock_location_flowable.action_picking_tree_blocked +msgid "Transfers allow you to move products from one location to another." +msgstr "Las transferencias le permiten mover productos de un lugar a otro." + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_uom_id +msgid "Unit of Measure" +msgstr "Unidad de Medida" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "You can only receive one product at location %s because a manufacturing " +"order must be generated and the location will be blocked. Create a partial " +"delivery for this product %s." +msgstr "Solo puedes recibir un producto en la ubicación %s porque se debe generar" +" una orden de producción y la ubicación será bloqueada. Crea una entrega parcial" +" para este producto %s." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "You cannot disable flowable storage from a blocked location." +msgstr "No puedes deshabilitar el almacenamiento fluido de una ubicación bloqueada" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/mrp_production.py:0 +#, python-format +msgid "You cannot cancel a production with a picking associated. The mixing is in progress." +msgstr "No se puede cancelar una producción que tenga un albarán asociado. La mezcla está en curso." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "You cannot convert this location into a flowable location because there are " +"unmixed products or products with different units of measure." +msgstr "No puedes convertir esta ubicación en una ubicación fluida porque hay " +"productos sin mezclar o productos con diferentes unidades de medida." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_quant.py:0 +#, python-format +msgid "You cannot have more than one lot in the same location." +msgstr "No se puede tener más de un lote en la misma ubicación." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/mrp_production.py:0 +#: code:addons/stock_location_flowable/models/stock_move.py:0 +#, python-format +msgid "You cannot modify a mix production with a picking associated. The mixing is in progress." +msgstr "No se puede modificar una producción de mezcla que tenga un albarán asociado. La mezcla está en curso." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "You cannot remove a product that is currently stored in this location." +msgstr "No puedes eliminar un producto que esté actualmente almacenado en esta ubicación." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_return_picking.py:0 +#, python-format +msgid "You cannot return the product %s because it comes from a flowable location %s." +msgstr "No puedes devolver el producto %s porque proviene de una ubicación fluida %s." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "You have stock movements with different unit of measure" +msgstr "Tienes movimientos de stock con diferente unidad de medida" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "You must select a sequence" +msgstr "Debes seleccionar una secuencia." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "You must select a unit of measure" +msgstr "Debes seleccionar una unidad de medida." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "You must select products" +msgstr "Debes seleccionar productos." diff --git a/stock_location_flowable/models/__init__.py b/stock_location_flowable/models/__init__.py new file mode 100644 index 000000000..ea5324afe --- /dev/null +++ b/stock_location_flowable/models/__init__.py @@ -0,0 +1,8 @@ +from . import stock_location +from . import stock_picking +from . import mrp_production +from . import stock_return_picking +from . import stock_move_line +from . import stock_picking_type +from . import stock_move +from . import stock_quant diff --git a/stock_location_flowable/models/mrp_production.py b/stock_location_flowable/models/mrp_production.py new file mode 100644 index 000000000..c92fcbcb7 --- /dev/null +++ b/stock_location_flowable/models/mrp_production.py @@ -0,0 +1,52 @@ +# Copyright NuoBiT Solutions - Frank Cespedes +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class MrpProduction(models.Model): + _inherit = "mrp.production" + + picking_id = fields.Many2one(comodel_name="stock.picking") + production_blocked = fields.Boolean(compute="_compute_production_blocked") + + def _compute_production_blocked(self): + for rec in self: + rec.production_blocked = bool( + self.env["stock.location"].search_count( + [("flowable_production_id", "=", rec.id)] + ) + ) + + @api.constrains("product_id", "move_raw_ids", "location_dest_id") + def _check_production_lines(self): + for rec in self: + if not rec.picking_type_id.flowable_operation: + super(MrpProduction, rec)._check_production_lines() + + @api.constrains("state") + def _check_flowable_blocked(self): + for rec in self: + if rec.state == "cancel" and rec.picking_id: + raise ValidationError( + _( + "You cannot cancel a production with a picking associated." + " The mixing is in progress." + ) + ) + + def write(self, vals): + for rec in self: + if ( + rec.picking_id + and rec.picking_type_id.flowable_operation + and rec.state == "to_close" + ): + raise ValidationError( + _( + "You cannot modify a mix production with a picking associated." + " The mixing is in progress." + ) + ) + return super().write(vals) diff --git a/stock_location_flowable/models/stock_location.py b/stock_location_flowable/models/stock_location.py new file mode 100644 index 000000000..e143d6def --- /dev/null +++ b/stock_location_flowable/models/stock_location.py @@ -0,0 +1,227 @@ +# Copyright NuoBiT Solutions - Frank Cespedes +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError +from odoo.tools.safe_eval import json + + +class Location(models.Model): + _inherit = "stock.location" + + flowable_storage = fields.Boolean() + flowable_blocked = fields.Boolean(compute="_compute_flowable_blocked") + flowable_blocked_popover = fields.Char( + string="JSON data for the popover widget", + compute="_compute_flowable_blocked_popover", + ) + flowable_capacity = fields.Float(string="Capacity") + flowable_uom_id = fields.Many2one(string="Unit of Measure", comodel_name="uom.uom") + flowable_sequence_id = fields.Many2one( + string="Sequence", comodel_name="ir.sequence", check_company=True + ) + flowable_allowed_product_ids = fields.Many2many( + string="Allowed Products", comodel_name="product.product" + ) + flowable_production_id = fields.Many2one( + comodel_name="mrp.production", + ) + flowable_capacity_occupied = fields.Float( + compute="_compute_flowable_capacity_occupied", store=True + ) + flowable_percentage_occupied = fields.Float( + compute="_compute_flowable_percentage_occupied" + ) + flowable_create_lots = fields.Boolean() + + def _compute_flowable_blocked_popover(self): + for rec in self: + rec.flowable_blocked_popover = json.dumps( + { + "title": _("Flowable Location Warning"), + "msg": _( + "This location is blocked because it has " + "a manufacturing order assigned." + ), + } + ) + + @api.depends("flowable_production_id") + def _compute_flowable_blocked(self): + for rec in self: + rec.flowable_blocked = bool( + rec.flowable_storage and rec.flowable_production_id + ) + + @api.depends("quant_ids.quantity") + def _compute_flowable_capacity_occupied(self): + for rec in self: + if rec.flowable_storage: + rec.flowable_capacity_occupied = sum(rec.quant_ids.mapped("quantity")) + + @api.depends("flowable_capacity_occupied") + def _compute_flowable_percentage_occupied(self): + for rec in self: + if rec.flowable_capacity <= 0: + rec.flowable_percentage_occupied = 0 + else: + rec.flowable_percentage_occupied = ( + rec.flowable_capacity_occupied / rec.flowable_capacity * 100 + ) + + def action_view_mrp_production(self): + self.ensure_one() + return { + "name": _("Manufacturing Orders"), + "type": "ir.actions.act_window", + "view_mode": "form", + "res_model": "mrp.production", + "res_id": self.flowable_production_id.id, + } + + @api.constrains("flowable_uom_id") + def _check_flowable_uom_id(self): + for rec in self: + if rec.flowable_storage: + if rec.quant_ids.filtered( + lambda x: x.product_uom_id != rec.flowable_uom_id and x.quantity > 0 + ): + raise ValidationError( + _("You have stock movements with different unit of measure") + ) + + @api.constrains("flowable_capacity_occupied") + def _check_flowable_capacity_occupied(self): + for rec in self: + if rec.usage != "view" and rec.flowable_storage: + if rec.flowable_capacity_occupied >= rec.flowable_capacity: + raise ValidationError(_("Location capacity is full")) + + @api.constrains("flowable_storage") + def _check_production_linked_flowable_location(self): + for rec in self: + if not rec.flowable_storage and rec.flowable_production_id: + raise ValidationError( + _("You cannot disable flowable storage from a blocked location.") + ) + + @api.constrains( + "flowable_storage", + "flowable_uom_id", + "flowable_allowed_product_ids", + "flowable_capacity", + "flowable_create_lots", + "flowable_sequence_id", + ) + def _check_required_fields_flowable_storage(self): + for rec in self: + if rec.usage != "view" and rec.flowable_storage: + if rec.flowable_capacity <= 0: + raise ValidationError(_("Capacity must be greater than 0")) + if rec.flowable_capacity < rec.flowable_capacity_occupied: + raise ValidationError( + _("Capacity must be greater than capacity occupied %s") + % rec.flowable_capacity_occupied + ) + if not rec.flowable_uom_id: + raise ValidationError(_("You must select a unit of measure")) + if not rec.flowable_allowed_product_ids: + raise ValidationError(_("You must select products")) + if rec.flowable_create_lots and not rec.flowable_sequence_id: + raise ValidationError(_("You must select a sequence")) + + @api.constrains("flowable_allowed_product_ids", "flowable_uom_id") + def _check_sequence_products_flowable_capacity(self): + for rec in self: + if rec.usage != "view" and rec.flowable_storage: + if rec.flowable_allowed_product_ids.filtered( + lambda x: x.tracking != "lot" + ): + raise ValidationError( + _("All allowed products must be tracked by lot") + ) + for product in rec.flowable_allowed_product_ids: + if product.uom_id != rec.flowable_uom_id: + raise ValidationError( + _( + "The product %s is measured in %s. You can only assign" + " products that have the allowed unit of measure" + ) + % (product.name, product.uom_id.name) + ) + + @api.depends("name", "location_id.complete_name", "usage", "flowable_blocked") + def _compute_complete_name(self): + for rec in self: + if rec.flowable_storage and rec.flowable_blocked: + rec.complete_name = "%s/%s [%s]" % ( + rec.location_id.complete_name, + rec.name, + _("Blocked"), + ) + else: + super(Location, rec)._compute_complete_name() + + def name_get(self): + res = [] + for rec in self: + name_l = [rec.name] + if rec.flowable_storage and rec.flowable_blocked: + name_l.append("[Blocked]") + res.append((rec.id, " ".join(name_l))) + return res + + def write(self, vals): + for rec in self: + old_allowed_products = self.env["product.product"] + if "flowable_allowed_product_ids" in vals and vals.get( + "flowable_storage", rec.flowable_storage + ): + old_allowed_products = rec.flowable_allowed_product_ids + if vals.get("flowable_storage"): + for product in rec.quant_ids.product_id: + product_quant = rec.quant_ids.filtered( + lambda x: x.quantity > 0 and x.product_id == product + ) + if len(product_quant) > 1: + raise UserError( + _( + "You cannot convert this location into a flowable location" + " because there are unmixed products." + ) + ) + if product.uom_id.id != vals.get( + "flowable_uom_id", rec.flowable_uom_id + ): + raise UserError( + _( + "You cannot convert this location into a flowable location" + " because there are products with different units of" + " measure." + ) + ) + elif not vals.get("flowable_storage", True): + vals.update( + { + "flowable_sequence_id": False, + "flowable_allowed_product_ids": False, + "flowable_capacity": 0, + "flowable_uom_id": False, + } + ) + res = super(Location, rec).write(vals) + if rec.flowable_storage: + removed_product_ids = set(old_allowed_products.ids) - set( + rec.flowable_allowed_product_ids.ids + ) + for product_id in removed_product_ids: + if rec.quant_ids.filtered( + lambda x: x.product_id.id == product_id and x.quantity > 0 + ): + raise UserError( + _( + "You cannot remove a product that is currently" + " stored in this location." + ) + ) + return res diff --git a/stock_location_flowable/models/stock_move.py b/stock_location_flowable/models/stock_move.py new file mode 100644 index 000000000..8e184a8f4 --- /dev/null +++ b/stock_location_flowable/models/stock_move.py @@ -0,0 +1,38 @@ +# Copyright NuoBiT Solutions - Frank Cespedes +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import _, models +from odoo.exceptions import UserError + + +class StockMove(models.Model): + _inherit = "stock.move" + + def write(self, vals): + for rec in self: + production = rec.raw_material_production_id + new_state = vals.get("state") + if ( + production.picking_type_id.flowable_operation + and production.picking_id + and production.state == "to_close" + and new_state != "done" + ): + raise UserError( + _( + "You cannot modify a production with a picking associated." + " The mixing is in progress." + ) + ) + elif ( + new_state in ("confirmed", "assigned", "partially_available") + and vals.get("move_line_ids", rec.move_line_ids) + and production.picking_type_id.flowable_operation + and production.location_dest_id.flowable_storage + and not production.location_dest_id.flowable_blocked + ): + production.location_dest_id.flowable_production_id = production + elif production.location_dest_id.flowable_production_id == production: + if new_state in ("cancel", "done"): + production.location_dest_id.flowable_production_id = False + return super().write(vals) diff --git a/stock_location_flowable/models/stock_move_line.py b/stock_location_flowable/models/stock_move_line.py new file mode 100644 index 000000000..3ea09ca3b --- /dev/null +++ b/stock_location_flowable/models/stock_move_line.py @@ -0,0 +1,43 @@ +# Copyright NuoBiT Solutions - Frank Cespedes +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class StockMoveLine(models.Model): + _inherit = "stock.move.line" + + raw_production_blocked = fields.Boolean( + related="move_id.raw_material_production_id.production_blocked" + ) + + @api.constrains("state") + def _check_flowable_location_blocked(self): + for rec in self: + if rec.state not in ("draft", "cancel"): + locations_to_check = self.env["stock.location"] + if ( + rec.location_dest_id.flowable_storage + and rec.location_dest_id.flowable_blocked + ): + locations_to_check |= rec.location_dest_id + if ( + rec.location_id.flowable_storage + and rec.location_id.flowable_blocked + ): + locations_to_check |= rec.location_id + for location in locations_to_check: + if rec.state == "done": + production = rec.move_id.production_id + else: + production = rec.move_id.raw_material_production_id + if production and location.flowable_production_id != production: + raise ValidationError( + _( + "The location %s is blocked. Probably you need to" + " review the pending manufacturing orders related" + " to this location" + ) + % location.name + ) diff --git a/stock_location_flowable/models/stock_picking.py b/stock_location_flowable/models/stock_picking.py new file mode 100644 index 000000000..9029b2757 --- /dev/null +++ b/stock_location_flowable/models/stock_picking.py @@ -0,0 +1,204 @@ +# Copyright NuoBiT Solutions - Frank Cespedes +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import _, fields, models +from odoo.exceptions import UserError + + +class StockPicking(models.Model): + _inherit = "stock.picking" + + flowable_production_ids = fields.One2many( + string="Manufacturing Orders", + comodel_name="mrp.production", + inverse_name="picking_id", + ) + + def action_view_mrp_production(self): + self.ensure_one() + action = self.env["ir.actions.act_window"]._for_xml_id( + "mrp.mrp_production_action" + ) + form = self.env.ref("mrp.mrp_production_form_view") + if len(self.flowable_production_ids) == 1: + action["views"] = [(form.id, "form")] + action["res_id"] = self.flowable_production_ids[0].id + else: + action["domain"] = [("id", "in", self.flowable_production_ids.ids)] + action["context"] = {**self.env.context, "search_default_todo": False} + return action + + def _prepare_lot_values(self, product, location_dest, qty_done): + self.ensure_one() + return { + "name": location_dest.flowable_sequence_id._next(), + "product_id": product.id, + "product_qty": qty_done, + "product_uom_id": product.uom_id.id, + } + + def _prepare_production_move_line_values(self, move_line, product, location_dest): + self.ensure_one() + return { + "lot_id": move_line.lot_id.id, + "product_id": product.id, + "qty_done": move_line.quantity, + "product_uom_id": product.uom_id.id, + "location_id": location_dest.id, + "location_dest_id": product.with_company( + self.company_id + ).property_stock_production.id, + } + + def _prepare_production_move_values( + self, product, location_dest, quantity_to_prod, mrp_operation_type + ): + self.ensure_one() + return { + "name": product.name, + "product_id": product.id, + "picking_type_id": mrp_operation_type.id, + "location_id": location_dest.id, + "location_dest_id": location_dest.id, + "product_uom": product.uom_id.id, + "product_uom_qty": quantity_to_prod, + } + + def _prepare_production_values( + self, product, location_dest, quantity_to_prod, mrp_operation_type + ): + self.ensure_one() + return { + "product_id": product.id, + "product_qty": quantity_to_prod, + "product_uom_id": product.uom_id.id, + "picking_type_id": mrp_operation_type.id, + "location_src_id": location_dest.id, + "location_dest_id": location_dest.id, + "picking_id": self.id, + "move_raw_ids": [ + ( + 0, + 0, + self._prepare_production_move_values( + product, location_dest, quantity_to_prod, mrp_operation_type + ), + ) + ], + } + + def button_validate(self): + for rec in self: + if rec.move_line_ids_without_package.filtered( + lambda x: x.location_dest_id.flowable_storage + or x.location_id.flowable_storage + ): + rec.env.context = dict(rec.env.context) + rec.env.context["allow_duplicate"] = True + return super().button_validate() + + def _action_done(self): + res = super()._action_done() + for rec in self: + lines = {} + for line in rec.move_line_ids_without_package: + if line.location_dest_id.flowable_storage: + key = (line.product_id, line.location_dest_id, line.lot_id) + lines[key] = lines.get(key, 0) + line.qty_done + if any(k[1] == line.location_dest_id and k != key for k in lines): + raise UserError( + _( + "You can only receive one product at location %s" + " because a manufacturing order must be generated" + " and the location will be blocked. Create a " + "partial delivery for this product %s." + ) + % (line.location_dest_id.name, line.product_id.name) + ) + for (product, location_dest, lot), qty_done in lines.items(): + if product not in location_dest.flowable_allowed_product_ids: + raise UserError( + _("Product %s not allowed in flowable location %s") + % (product.name, location_dest.name) + ) + if product.uom_id != location_dest.flowable_uom_id: + raise UserError( + _( + "The allowed products %s cannot have different Unit of" + " Measure than flowable location %s" + ) + % (product.name, location_dest.name) + ) + if product.tracking != "lot": + raise UserError( + _("Product %s must be tracked by lot") % product.name + ) + mrp_operation_type = rec.env["stock.picking.type"].search( + [ + ("warehouse_id", "=", rec.picking_type_id.warehouse_id.id), + ("code", "=", "mrp_operation"), + ("flowable_operation", "=", True), + ] + ) + if not mrp_operation_type: + raise UserError( + _( + "Not found manufacturing picking type for flowable" + " location %s to do flowable mixing in %s" + ) + % (location_dest.name, rec.picking_type_id.warehouse_id.name) + ) + if len(mrp_operation_type) > 1: + raise UserError( + _( + "More than one manufacturing code in picking type for" + " flowable location %s" + ) + % location_dest.name + ) + if not mrp_operation_type.sequence_id: + raise UserError( + _( + "Not found sequence in manufacturing picking type %s" + " for flowable location %s" + ) + % (mrp_operation_type.display_name, location_dest.name) + ) + component_quant = rec.env["stock.quant"].search( + [ + ("product_id", "=", product.id), + ("location_id", "=", location_dest.id), + ("quantity", ">", 0), + ("company_id", "=", rec.company_id.id), + ] + ) + quantity_to_prod = sum(component_quant.mapped("quantity")) + production = rec.env["mrp.production"].create( + rec._prepare_production_values( + product, location_dest, quantity_to_prod, mrp_operation_type + ) + ) + production._onchange_move_finished_product() + production._onchange_move_finished() + production._onchange_location_dest() + production.action_confirm() + if location_dest.flowable_create_lots: + lot = rec.env["stock.production.lot"].create( + rec._prepare_lot_values(product, location_dest, qty_done) + ) + vals = [] + for move_line in component_quant: + vals.append( + ( + 0, + 0, + rec._prepare_production_move_line_values( + move_line, product, location_dest + ), + ) + ) + production.move_raw_ids.move_line_ids = vals + production.lot_producing_id = lot + production.action_assign() + production.qty_producing = quantity_to_prod + return res diff --git a/stock_location_flowable/models/stock_picking_type.py b/stock_location_flowable/models/stock_picking_type.py new file mode 100644 index 000000000..4a0d94b78 --- /dev/null +++ b/stock_location_flowable/models/stock_picking_type.py @@ -0,0 +1,35 @@ +# Copyright NuoBiT Solutions - Frank Cespedes +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class PickingType(models.Model): + _inherit = "stock.picking.type" + + flowable_operation = fields.Boolean(copy=False) + + @api.constrains("flowable_operation", "code", "warehouse_id", "company_id") + def _check_flowable_operation(self): + for rec in self: + if rec.flowable_operation: + if rec.code != "mrp_operation": + raise ValidationError( + _("Only manufacturing picking types can be flowable."), + ) + if ( + rec.env["stock.picking.type"].search_count( + [ + ("flowable_operation", "=", True), + ("warehouse_id", "=", rec.warehouse_id.id), + ("code", "=", rec.code), + ("company_id", "=", rec.company_id.id), + ] + ) + > 1 + ): + raise ValidationError( + _("Only one picking type can be flowable in a warehouse %s.") + % rec.warehouse_id.name, + ) diff --git a/stock_location_flowable/models/stock_quant.py b/stock_location_flowable/models/stock_quant.py new file mode 100644 index 000000000..5e8565be8 --- /dev/null +++ b/stock_location_flowable/models/stock_quant.py @@ -0,0 +1,36 @@ +# Copyright NuoBiT Solutions - Frank Cespedes +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import _, api, models +from odoo.exceptions import ValidationError +from odoo.tools import float_compare + + +class StockQuant(models.Model): + _inherit = "stock.quant" + + @api.constrains("location_id", "quantity") + def _check_unique_lot(self): + for rec in self: + if not self.env.context.get("allow_duplicate"): + if rec.location_id.flowable_storage: + if ( + len( + rec.product_id.stock_quant_ids.filtered( + lambda x: float_compare( + x.quantity, + 0, + precision_rounding=rec.product_uom_id.rounding, + ) + > 0 + and x.location_id == rec.location_id + ) + ) + > 1 + ): + raise ValidationError( + _( + "You cannot have more than one" + " lot in the same location." + ) + ) diff --git a/stock_location_flowable/models/stock_return_picking.py b/stock_location_flowable/models/stock_return_picking.py new file mode 100644 index 000000000..b6cafff72 --- /dev/null +++ b/stock_location_flowable/models/stock_return_picking.py @@ -0,0 +1,26 @@ +# Copyright NuoBiT Solutions - Frank Cespedes +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import _, models +from odoo.exceptions import UserError + + +class ReturnPicking(models.TransientModel): + _inherit = "stock.return.picking" + + def _create_returns(self): + res = super()._create_returns() + for rec in self: + move_line = rec.picking_id.move_line_ids_without_package.filtered( + lambda x: x.location_dest_id.flowable_storage + and x.product_id in rec.product_return_moves.product_id + ) + if move_line: + raise UserError( + _( + "You cannot return the product %s because it" + " comes from a flowable location %s." + ) + % (move_line.product_id.name, move_line.location_dest_id.name) + ) + return res diff --git a/stock_location_flowable/readme/CONTRIBUTORS.rst b/stock_location_flowable/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..708936c8d --- /dev/null +++ b/stock_location_flowable/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `NuoBiT `_: + + * Frank Cespedes diff --git a/stock_location_flowable/readme/DESCRIPTION.rst b/stock_location_flowable/readme/DESCRIPTION.rst new file mode 100644 index 000000000..6499f0e5d --- /dev/null +++ b/stock_location_flowable/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +* Customizations that allow organizing, controlling, and mixing bulk liquid and solid products in a location. diff --git a/stock_location_flowable/static/description/icon.png b/stock_location_flowable/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1cd641e792c30455187ca30940bc0f329ce8bbb0 GIT binary patch literal 6342 zcmd^^hf`C}*TzHWpfm-MZa|7OjYtjE(4`3pP0Eid3J8V{0s)mKJ<q zp^9|rp$mb~2}po9-@oIXJG(oxcjoS%d!O@s&d!Z9HP*e##KQyt0IurmK_64bp8pyH z9i^|ds>-JfbWVo4P{8GX*QeIfbjl2)kDfIG0ALvZuTgp2ZfK=U();NfY11z-vM>r= zo6RyI007+P`cO@apy}VqnaiVCLL`CEUGVGYE&5WpdhhbZv%|*-Y|2t(4~Cq|y`-Nmm-W zxaTf4+R69rVU1b%qjm?yu*PFgHFYd#J82-D8cpXqO&omwG2*Hd6ZIUiK@+ zNCo8Lg{1^vn^0ZQgz*~*ZR3wsULxnnSBN%7p()3EYs>sX9In)T{*nJ2q*qxXPNhFk z=z=+?4VOOdAF!ZYAVisYzF29g?udLQJtx@=HoAK_Kjx;4SO7>H_v*McB7(}RHMa> z+PNao{Hw&Mjo0P}CBR&l(k@iIeRI@PRH6R9^lR3e?TL?ZHra#GHvKmkeVBHG8nv4{ zz$nHGR7`D$ae@TrcXCSA=$~Yvp@J|bKul>6s-`yT7>JaM5?KcltZ)(ilt^74fqLA{ z1k!bKw(GMV*AOgI*glG_($h!cZgArkEAa1SkSG`0yF8JLWTq^J->2CRaqKH1ZSQt7 z29|+OBS3Rj91K1XL~_9&zn1p z)2Ez)&{9Of1X#b+mpgJ`{gurrlYqKrwrWXTOH{M%kEUhcgSp1J2FK4FF`JS|NfaAA6)?-&1}B`@lI2~kKWK) zhQ|}GQ$j(rNS}9?Yu9}MzWxz*HMwR=u8$RYY6sr2pu3x5Yx*P!Z&c|X zFZcC{+kqJV=XTZH=cMb6)MtgWo%C~XU8TEXDKx9;0hEV*74Z6i8vuzXp zw<8QvI~;n;3@<^G0C#HHf2{N6E~2DO3jw!?w}z?_vV6Q>?kJ>IF-kEc*TtP}k7cVd zvtdPgQ^jWhMXAL$Lqn!_A_IL+!hbY37)n@Sqc)6JwD4)3LP`up1cy^EXzh>B{$ce0 zgX~Iat{I@DM|zU|>9DuD?g}h7zCqV;o1*~3Hr=DYjDq;SG?3HS)(x+l@HAa-@>5wH zhw`oqg>hP$e41h5)>$#qFWq?LGX`dC8ph`RyR&_z&og>psSHzZ=_8<-M4yk+3HK-+ zxqe%Ntx88}49jJazM_Vov;)83cSeeLv@taHOL>zP>~bqdmEyfHl9M%`@ivb|7{I;N zzyHw9P7EH0$ww52RejJv>zvSr8v*iuX@X;(Z~NuUv$D0I_>OkcZWSulBUJjHUN=n| zSI$q@$)`(E;^(|}q|2utYl8}>IcXkPX#{6Z%JnhUBly1B@B}sECm2Y88-QrQZd2n2 zKL=1_&Z87xM=GaycA-Ac*R<^bJk>-^k%lt;DjswC+AM`71*2iG?;!3Bc)I>55v)^C zkt+Uzn&dhv|58XAY6{%ybSiVMl-sATTy=SUADQWD+(@-AVqg@Y+_fBV$LJnIEfujI4B5%4a@8S4M*50Lh7NqKSW>K=U5dW@)Hd{^oR4v% zCM2(rAq7Qe-)R0ko{l@iCHGsxhkCNWby zf&gByp!>=?r1ecWMqz5e-BmOED6n!_1V4<)R!!QNwM!AyGty8>p>ebEzdp*_(kAYA z5*F^g_K}%Rm;V}4Q46qJpU+&3bU10WYg{j`T>lv9{B)J}RHC}yzy9x)wm4ju23yQ& zUNm(i_(ChqD8d7AVUFMw zXmia0A{l#}Sfq!GmHjatiTk$f|OvS0iG>W{p<8cZu^6HX`rMuX?l8<+?WVAW6 z3!MLV*VOFpd&STaeN2qdwU* zk1ni(wdh{`{hLj-hCz&59jVIp~SmgtSQDf!FrPYKIF6_c_NJr zn<-BdXVU}OSE{-No~b(6tG)250`-S%YB9Si@&}{d@FUGqjcNE@SlSdG`}H-#!~M1& z;{E-SKUBb6)KwP1XB|S8MB=F>9k$#1$|^*t%%5zq#(35~S#+TgC^oj&COt~T>axhU0t zQff{8Jt+NH^_pqPzec@Iv#L^r?qs$jdiCY&xOU2pve78Pc{a8y+D;2N0aEJe5d#uL}ZkkYQ&XA;NK5v>r@NUaj=<_V$*Ll@&CF!{LWI zh@|EE!!M(B5qeQ40YHy86TVkX6Te=v4ytV_-JnKl93#Z9clghd^lywoBtgj)4%mxKR<#pH0*hxyHFQNJ zGW`7CtD9C6)ehKni=#!gKj#ZO7L$d_i4nJZhR!z$B(rX9j$$L8X1>~^2By%Dp*IJj z8QiI6*w*|IoF{UpFaD{!PWdOxja{DQq9?BK%2(Xuh#Tv2s_ELIvb@YAd{Af)Lph(9 z>DTXZ`|*!Jnw)?`BzPrdYx(?S2&<(1>1>-f=c}gi8^)=KW973rikh?!-B$fOy@x-Rd+?x= zM(0SbmCz!gY#)CqB9J_^v4K$urOnoj|E||~D>%ndVMwe)ef3BuZH0l!Z&M@fyN}{1 zD;n{juZF|*{lehy$NlM{B`Q0Z18O|&=wX!Nt*rLKfak}ww{ zJ$9BJA3Tq4n~%w3V$0UA(+PgZ#j-35$=_xzuk(w5o2f(WOCu%+h>cg3B*aqaQdfeQ zj@VutKTWtH8{S+}vR3Z`KIQl-h!4tFi1vG-Kuh^Lb0N=LN0+1ZP!WL39=Age)HS_E z8khUbE>xA^59Nmj`B0@u0IR<04wqF@ssF4AP6ZVhslN61xT#8o@ymhOWJ5zkUQN07 zyDEYVZ4#Z$(%wnd04Y_^B_4gjFoKPWgD&OUsj^ezcuXa}E4yjc@xi#az zyRy6>?#h2*VNdNO_jYQ1{@qaYoN7moT}cnd8cmK*&R@SeSYZgIBaJklh!n-3#3dyO z!@*@06=Y8#wl9|Bj3=C0Fi!SfzVz7$Stc4_Q`K2P?2|gT!JIBhc*P&-IkB?Mb5I&% z%BN*TF#vYzIW>)|=X`Chr};G5EZXg?_yvlDC|f%AP!ty{i{{pXQnHm<^|{P$D; z9ZAW#l9Cd2($R5@*5}FeUd#l;N11WwITb1nJSm8r@`#sXHPsuq!3S2&h>U)y=3MjV;j3oWLY>5EOvuruXC*WH2G){378-0tpcMF}1(^PSWUe>XEJN%5 zl|m59cX=GC{^$_E-4Wm1=5|!;Ek&{<4lIOt5M&GMq=+JQdyt?WI#6C!)i!s4;k9T0 z{;`B*>VQ%iU)>Zbhgb4|vd=Wy4>107#gyeqi^+-^2E~0Ja&rFpRb<)oirMj4-KuLg zSo1*y98TZlD<3^A&^bRESh~S*Lzqn0l;JfX-fdjA`M#a!@?b?zWdEr3mIiqS{m2J% z3nWGoQG6+FQ~&gQF-DLGWF}WfwHL(4$EUt(5Jcx#l79K-x~qdu!_gs;XaP0`8m(8a z2J#B{UvEhLT=w9*(6bFWp{9CI=Z&Hh)e}}1hnK6fPlSYqu4H|>g|Erg5fVWl5w&~Kdf{3+V{dCaNhFDg<~sELf1dC($hw|SmSkZ zKD6>nsj6Q+aHEZDHC9{UJxPZ9y{6)F5hg5bm*}ihsxQxj~`xNo%QnaTEJn)f#{CK-H5HYAM7kK zL!XvElM^Y!yC=uSu54Gj zTEgKhtTCOqx1EcIl=VA7`!xLiUj%p*eH??_??@gOJJxVX)#(G`=31lw3whFi2Y7Mq z1bXLvi+~U5E4R{v15H@yQI@=d!V9LD&P!p?0u7L&Rg=D<<*+ zouj?2?aYI{Ac%Gx!r&EkXmmvR`!Xl?06WsGs_Ts8ojW?id!X$>C}@~q>BMfGeGohw zkR}NImw2grp7>W(5s*(iPYn$1*t@i%(W7u#6m}l)%TmD-221>N?VBna!@FO-7!xjM z{`_^-yt<@e?fK$Sqzc7O%3&~A>HB|stQr64jx(U3y+}d}vp(r7c=iB8>t~T7HmYg1qJe4SLo$e62=EZUuFS7UqbSP}M^@%aI7g!ztzj{)_R0x*X6OMLAky)_Sv&%2DNGv zxH}pEr{gEYf&ZF&RJoII9*=yd^~fxKtFc@1f_3}Vqqi8_U?;lC`7etN$3$u0dW+-%7P zQ~iX&gr(5xd1M>3yrzZav9ZLIhbS&|=U$t!9iq*i5vy)(RsBw0TU#?~zdTKUXjyIl z%7Q)Vp}YoU$acz-9y_`%Oig!%TPyC=ie3*Qut3@4V`+A4d<*f%jOx>*bX%#Ao+@wM z;NW0DZKvmp%_oxvFw2#S9r8Sc?wXh}`3gVG`rBKr&jpxwTRQ7WtKY06QQVhs$u$!e zs;Y%~2xwpH*9vxfQ~q#gAwn+P+=YE(L>|P(Fl&H27@?);kUI4FW%LjHZKYGk#f~@3 zXW;a;3+{&c`g+uCR+``$V9)N#RBCk_#RQ(K-PxlQ7Ym;XdCqGn$j%JmAwgtkWKn1} z8^>3&)Q05VbBm+t`9B_${w9F7WfM{Jvawk;HDc*{Sa_Sla|zqX!vbKV%>gB|z6BCc z8_bdnPnzloGP1I)!^5hnC6CLZUU`;nO2NF2)FaAkYhQL$Z58+`p75dj7RKse#Z!uacCm z0@|m~U!QZOdb|V~`ktFK4;lg_ZOCjFXeV4`jGj&bh7Q6BEyN8~yGd*JyzwFbIRaAf z#KG$rvQxWFvqwn`i6jBQ?6o+k+oOC)Gj9ChlgabiScr};b5|opxUYjCZOwmhjTj6W zFzJt_htTuopW4IRiQ}r0L}`w=pE{HN<@(9Hl11P5cHmN6A1F^sg2OWXcw<+q2x>I5 zq9Bu>PBob6#^vrr<|IC)m+zJpFRRcCVsqbspNybriu&!R=H^@RcG#aBGz9RH}ZI=>4 zi(m?IA?Vr$Q7?wN6ZW7H`S?3}K8=$7J5MjWKri=_igw1%J?0~*6e_Ii*1&23dGcF} z&=vaMgF!^veGQ1f$3k?WK5Jaw%==+Bb!tI6zQ68&-dQ3Orl+Tqh#Nt?dBEV_w^wkjY+qJ+X*NCMs%J-Lc4%}pKryM#O)O&9 un*HHVB-AlUN`suyDkKONktc!@Ievk;6wT20MOSqhE{1gM*SZGeqiYU literal 0 HcmV?d00001 diff --git a/stock_location_flowable/static/description/index.html b/stock_location_flowable/static/description/index.html new file mode 100644 index 000000000..e27cb45d6 --- /dev/null +++ b/stock_location_flowable/static/description/index.html @@ -0,0 +1,421 @@ + + + + + + +Stock Location Flowable + + + +
+

Stock Location Flowable

+ + +

Beta License: AGPL-3 NuoBiT/odoo-addons

+
    +
  • Customizations that allow organizing, controlling, and mixing bulk liquid and solid products in a location.
  • +
+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • NuoBiT Solutions
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is part of the NuoBiT/odoo-addons project on GitHub.

+

You are welcome to contribute.

+
+
+
+ + diff --git a/stock_location_flowable/tests/__init__.py b/stock_location_flowable/tests/__init__.py new file mode 100644 index 000000000..c6930b66c --- /dev/null +++ b/stock_location_flowable/tests/__init__.py @@ -0,0 +1,5 @@ +from . import test_stock_location +from . import test_common +from . import test_stock_picking +from . import test_stock_picking_type +from . import test_mrp_production diff --git a/stock_location_flowable/tests/test_common.py b/stock_location_flowable/tests/test_common.py new file mode 100644 index 000000000..f8257c8c1 --- /dev/null +++ b/stock_location_flowable/tests/test_common.py @@ -0,0 +1,200 @@ +# Copyright NuoBiT Solutions - Frank Cespedes +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging +import re + +from odoo.tests import common + +_logger = logging.getLogger(__name__) + + +class TestCommon(common.SavepointCase): + @classmethod + def setUpClass(cls): + super(TestCommon, cls).setUpClass() + + cls.picking_type_incoming_1 = cls.env["stock.picking.type"].create( + { + "name": "Receipt1", + "sequence_code": "SEQ-IN", + "code": "incoming", + "default_location_dest_id": cls.env.ref( + "stock.stock_location_locations_partner" + ).id, + } + ) + + cls.picking_type_outgoing_1 = cls.env["stock.picking.type"].create( + { + "name": "Delivery1", + "sequence_code": "SEQ-OUT", + "code": "outgoing", + "default_location_src_id": cls.env.ref( + "stock.stock_location_locations_partner" + ).id, + } + ) + + cls.picking_type_internal_1 = cls.env["stock.picking.type"].create( + { + "name": "InternalTransfer1", + "sequence_code": "SEQ-INT", + "code": "internal", + "default_location_src_id": cls.env.ref( + "stock.stock_location_locations_partner" + ).id, + "default_location_dest_id": cls.env.ref( + "stock.stock_location_locations_partner" + ).id, + } + ) + + cls.picking_type_mrp_operation_1 = cls.env["stock.picking.type"].create( + { + "name": "Production1", + "sequence_code": "SEQ-MRP", + "code": "mrp_operation", + } + ) + + cls.product_flowable_1 = cls.env["product.product"].create( + { + "name": "ProductFlowable1", + "type": "product", + "uom_id": cls.env.ref("uom.product_uom_litre").id, + "uom_po_id": cls.env.ref("uom.product_uom_litre").id, + "tracking": "lot", + } + ) + + cls.product_flowable_2 = cls.env["product.product"].create( + { + "name": "ProductFlowable2", + "type": "product", + "uom_id": cls.env.ref("uom.product_uom_litre").id, + "uom_po_id": cls.env.ref("uom.product_uom_litre").id, + "tracking": "lot", + } + ) + + cls.location_1 = cls.env["stock.location"].create( + { + "name": "Location1", + "usage": "internal", + "location_id": cls.env.ref("stock.stock_location_locations_partner").id, + } + ) + + cls.location_flowable_1 = cls.env["stock.location"].create( + { + "name": "LocationFlowable1", + "usage": "internal", + "location_id": cls.env.ref("stock.stock_location_locations_partner").id, + "flowable_storage": True, + "flowable_capacity": 1000, + "flowable_uom_id": cls.env.ref("uom.product_uom_litre").id, + "flowable_allowed_product_ids": [ + (4, cls.product_flowable_1.id), + (4, cls.product_flowable_2.id), + ], + } + ) + + cls.location_flowable_2 = cls.env["stock.location"].create( + { + "name": "LocationFlowable2", + "usage": "internal", + "location_id": cls.env.ref("stock.stock_location_locations_partner").id, + "flowable_storage": True, + "flowable_capacity": 1500, + "flowable_uom_id": cls.env.ref("uom.product_uom_litre").id, + "flowable_allowed_product_ids": [(4, cls.product_flowable_1.id)], + "flowable_create_lots": True, + "flowable_sequence_id": cls.env.ref("stock.sequence_tracking").id, + } + ) + + lot_1 = cls.env["stock.production.lot"].create( + { + "name": "Lot1", + "product_id": cls.product_flowable_1.id, + } + ) + + cls.incoming_picking = cls.env["stock.picking"].create( + { + "picking_type_id": cls.picking_type_incoming_1.id, + "location_id": cls.location_flowable_1.id, + "location_dest_id": cls.location_flowable_1.id, + } + ) + + cls.env["stock.move.line"].create( + { + "picking_id": cls.incoming_picking.id, + "product_id": cls.product_flowable_1.id, + "product_uom_id": cls.product_flowable_1.uom_id.id, + "lot_id": lot_1.id, + "qty_done": 10, + "location_id": cls.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": cls.incoming_picking.location_dest_id.id, + "company_id": cls.env.company.id, + } + ) + + cls.outgoing_picking = cls.env["stock.picking"].create( + { + "picking_type_id": cls.picking_type_outgoing_1.id, + "location_id": cls.location_flowable_1.id, + "location_dest_id": cls.location_flowable_1.id, + } + ) + + cls.env["stock.move.line"].create( + { + "picking_id": cls.outgoing_picking.id, + "product_id": cls.product_flowable_1.id, + "product_uom_id": cls.product_flowable_1.uom_id.id, + "lot_id": lot_1.id, + "qty_done": 10, + "location_id": cls.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": cls.outgoing_picking.location_dest_id.id, + "company_id": cls.env.company.id, + } + ) + + cls.internal_picking = cls.env["stock.picking"].create( + { + "picking_type_id": cls.picking_type_internal_1.id, + "location_id": cls.location_flowable_1.id, + "location_dest_id": cls.location_flowable_2.id, + } + ) + + cls.env["stock.move.line"].create( + { + "picking_id": cls.internal_picking.id, + "product_id": cls.product_flowable_1.id, + "product_uom_id": cls.product_flowable_1.uom_id.id, + "lot_id": lot_1.id, + "qty_done": 10, + "location_id": cls.env.ref("stock.stock_location_inter_wh").id, + "location_dest_id": cls.internal_picking.location_dest_id.id, + "company_id": cls.env.company.id, + } + ) + + cls.mrp_picking = cls.env["stock.picking"].create( + { + "picking_type_id": cls.picking_type_mrp_operation_1.id, + "location_id": cls.location_flowable_1.id, + "location_dest_id": cls.location_flowable_2.id, + } + ) + + def get_error_message_regex(self, str1): + parts = str1.split("%s") + escaped_parts = [re.escape(part) for part in parts] + regex_pattern = ".*".join(escaped_parts) + return regex_pattern diff --git a/stock_location_flowable/tests/test_mrp_production.py b/stock_location_flowable/tests/test_mrp_production.py new file mode 100644 index 000000000..d3deff21d --- /dev/null +++ b/stock_location_flowable/tests/test_mrp_production.py @@ -0,0 +1,43 @@ +# Copyright NuoBiT Solutions - Frank Cespedes +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging + +from odoo.exceptions import UserError + +from .test_common import TestCommon + +_logger = logging.getLogger(__name__) + + +class TestMrpProduction(TestCommon): + @classmethod + def setUpClass(cls): + super(TestMrpProduction, cls).setUpClass() + + def test_blocked_flowable_mrp_operation(self): + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + # ACT + self.incoming_picking.button_validate() + + # ASSERT + self.assertTrue(self.incoming_picking.location_dest_id.flowable_blocked) + + def test_block_new_production_flowable_location_by_outgoing_picking(self): + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + self.incoming_picking.button_validate() + + # ACT + with self.assertRaises(UserError) as error: + self.outgoing_picking.button_validate() + + # ASSERT + msg_error = ( + "The location %s is blocked. Probably you need to review" + " the pending manufacturing orders related to this location" + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) diff --git a/stock_location_flowable/tests/test_stock_location.py b/stock_location_flowable/tests/test_stock_location.py new file mode 100644 index 000000000..ff53a7317 --- /dev/null +++ b/stock_location_flowable/tests/test_stock_location.py @@ -0,0 +1,341 @@ +# Copyright NuoBiT Solutions - Frank Cespedes +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging + +from odoo.exceptions import ValidationError + +from .test_common import TestCommon + +_logger = logging.getLogger(__name__) + + +class TestStockLocation(TestCommon): + @classmethod + def setUpClass(cls): + super(TestStockLocation, cls).setUpClass() + cls.uom_litre = cls.env.ref("uom.product_uom_litre") + cls.uom_unit = cls.env.ref("uom.product_uom_unit") + cls.product_flowable_1 = cls.env["product.product"].create( + { + "name": "Test CO2 1", + "type": "product", + "uom_id": cls.uom_litre.id, + "uom_po_id": cls.uom_litre.id, + } + ) + cls.warehouse_bcn = cls.env["stock.warehouse"].create( + { + "name": "Test Barcelona", + "code": "BCN", + } + ) + cls.location_flowable_bcn_1 = cls.env["stock.location"].create( + { + "name": "Test Flowable bcn 1", + "location_id": cls.warehouse_bcn.lot_stock_id.id, + } + ) + + # check + def test_required_field_flowable_capacity(self): + """ + Test to ensure that 'flowable_capacity' field is required when + 'flowable_storage' is True. + + PRE: - location_flowable_bcn_1 exists + - 'flowable_storage' is set to True + ACT: - Attempt to write to location_flowable_bcn_1 with 'flowable_storage' + but without 'flowable_capacity' + POST: - ValidationError is raised + """ + # ARRANGE & ACT + with self.assertRaises(ValidationError) as error: + self.location_flowable_bcn_1.write( + { + "flowable_storage": True, + } + ) + + # ASSERT + msg_error = "Capacity must be greater than 0" + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + # check + def test_required_field_flowable_uom_id(self): + """ + Test to ensure that 'flowable_uom_id' field is required when 'flowable_storage' + is True. + + PRE: - location_flowable_bcn_1 exists + - 'flowable_storage' is set to True + - 'flowable_capacity' is set + ACT: - Attempt to write to location_flowable_bcn_1 with 'flowable_storage' + and 'flowable_capacity' but without 'flowable_uom_id' + POST: - ValidationError is raised + """ + # ARRANGE & ACT + with self.assertRaises(ValidationError) as error: + self.location_flowable_bcn_1.write( + { + "flowable_storage": True, + "flowable_capacity": 100.0, + } + ) + + # ASSERT + msg_error = "You must select a unit of measure" + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + # check + def test_required_field_flowable_allowed_product_ids(self): + """ + Test to ensure that 'flowable_allowed_product_ids' field is required when + 'flowable_storage' is True. + + PRE: - location_flowable_bcn_1 exists + - 'flowable_storage' is set to True + - 'flowable_capacity' is set + - 'flowable_uom_id' is set + ACT: - Attempt to write to location_flowable_bcn_1 with 'flowable_storage', + 'flowable_capacity' and + 'flowable_uom_id' but without 'flowable_allowed_product_ids' + POST: - ValidationError is raised + """ + # ARRANGE & ACT + with self.assertRaises(ValidationError) as error: + self.location_flowable_bcn_1.write( + { + "flowable_storage": True, + "flowable_capacity": 100.0, + "flowable_uom_id": self.uom_litre.id, + } + ) + + # ASSERT + msg_error = "You must select products" + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + # check + def test_flowable_allowed_product_ids_tracked_by_lot(self): + """ + Test to ensure that 'flowable_allowed_product_ids' field has products tracked + by lot. + + PRE: - location_flowable_bcn_1 exists + - 'flowable_storage' is set to True + - 'flowable_capacity' is set + - 'flowable_uom_id' is set + - 'flowable_allowed_product_ids' is set + ACT: - Attempt to write to location_flowable_bcn_1 with 'flowable_storage', + 'flowable_capacity', + 'flowable_uom_id' and 'flowable_allowed_product_ids' but without + products tracked by lot + POST: - ValidationError is raised + """ + # ARRANGE & ACT + with self.assertRaises(ValidationError) as error: + self.location_flowable_bcn_1.write( + { + "flowable_storage": True, + "flowable_capacity": 100.0, + "flowable_uom_id": self.uom_litre.id, + "flowable_allowed_product_ids": [(4, self.product_flowable_1.id)], + } + ) + + # ASSERT + msg_error = "All allowed products must be tracked by lot" + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + # check + def test_successful_flowable_location_update(self): + """ + Test to ensure that a location can be successfully updated with all required + fields. + + PRE: - location_flowable_bcn_1 exists + - Necessary fields are prepared (uom_litre, product_flowable_1) + ACT: - Write to location_flowable_bcn_1 with all required fields + POST: - No errors are raised + - All fields are correctly updated + """ + # ARRANGE + self.product_flowable_1.tracking = "lot" + + self.location_flowable_bcn_1.write( + { + "flowable_storage": True, + "flowable_capacity": 100.0, + "flowable_uom_id": self.uom_litre.id, + "flowable_allowed_product_ids": [(4, self.product_flowable_1.id)], + } + ) + + # ACT & ASSERT + self.assertTrue(self.location_flowable_bcn_1.flowable_storage) + self.assertEqual(self.location_flowable_bcn_1.flowable_capacity, 100.0) + self.assertEqual( + self.location_flowable_bcn_1.flowable_uom_id.id, self.uom_litre.id + ) + self.assertIn( + self.product_flowable_1.id, + self.location_flowable_bcn_1.flowable_allowed_product_ids.ids, + ) + + # check + def test_adding_product_with_incompatible_uom(self): + """ + Test to ensure that adding a product with a unit of measure different from + 'flowable_uom_id' + raises an error. + + PRE: - Location exists with 'flowable_storage' set to True and a certain + 'flowable_uom_id' + - A product with a different 'uom_id' exists + ACT: - Attempt to add this product to 'flowable_allowed_product_ids' + POST: - ValidationError is raised stating that only products with the allowed + unit of measure can be assigned + """ + # ARRANGE + product_flowable_2 = self.env["product.product"].create( + { + "name": "Test CO2 2", + "type": "product", + "uom_id": self.uom_unit.id, + "uom_po_id": self.uom_unit.id, + "tracking": "lot", + } + ) + + # ACT + with self.assertRaises(ValidationError) as error: + self.location_flowable_bcn_1.write( + { + "flowable_storage": True, + "flowable_capacity": 100.0, + "flowable_uom_id": self.uom_litre.id, + "flowable_allowed_product_ids": [(4, product_flowable_2.id)], + } + ) + + # ASSERT + msg_error = ( + "The product %s is measured in %s. You can only assign" + " products that have the allowed unit of measure" + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + # check + def test_changing_to_incompatible_uom_id(self): + """ + Test to ensure that changing 'flowable_uom_id' to a unit of measure different + from that of + allowed products raises an error. + + PRE: - Location exists with 'flowable_storage' set to True and a certain + 'flowable_uom_id' + - 'flowable_allowed_product_ids' contains products with the current + 'flowable_uom_id' + ACT: - Attempt to change 'flowable_uom_id' to a different unit of measure + POST: - ValidationError is raised stating that only products with the allowed unit + of measure can be assigned + """ + # ARRANGE + product_flowable_2 = self.env["product.product"].create( + { + "name": "Test CO2 2", + "type": "product", + "uom_id": self.uom_unit.id, + "uom_po_id": self.uom_unit.id, + "tracking": "lot", + } + ) + + self.location_flowable_bcn_1.write( + { + "flowable_storage": True, + "flowable_capacity": 100.0, + "flowable_uom_id": self.uom_unit.id, + "flowable_allowed_product_ids": [(4, product_flowable_2.id)], + } + ) + + # ACT + with self.assertRaises(ValidationError) as error: + self.location_flowable_bcn_1.write( + { + "flowable_uom_id": self.uom_litre.id, + } + ) + + # ASSERT + msg_error = ( + "The product %s is measured in %s. You can only assign" + " products that have the allowed unit of measure" + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + # check + def test_check_flowable_sequence_id(self): + # ARRANGE + self.product_flowable_1.tracking = "lot" + + self.location_flowable_bcn_1.write( + { + "flowable_storage": True, + "flowable_capacity": 100.0, + "flowable_uom_id": self.uom_litre.id, + "flowable_allowed_product_ids": [(4, self.product_flowable_1.id)], + } + ) + + # ACT + with self.assertRaises(ValidationError) as error: + self.location_flowable_bcn_1.write( + { + "flowable_create_lots": True, + } + ) + + # ASSERT + msg_error = "You must select a sequence" + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + # Proposals for possible future tests to implement + # + # def test_flowable_capacity_constraints(self): + # with self.assertRaises(ValidationError): + # self.location.flowable_capacity = 50 + # self.location.flowable_capacity = 150 + # self.assertEqual(self.location.flowable_capacity, 150, "La capacity debe + # actualizarse good") + # + # def test_product_uom_constraints(self): + # self.location.write({ + # 'flowable_allowed_product_ids': [(4, self.product_flowable_1.id)], + # 'flowable_uom_id': self.env.ref('uom.product_uom_dozen').id + # }) + # with self.assertRaises(ValidationError): + # self.location._check_flowable_uom_id() + # + # def test_write_method(self): + # with self.assertRaises(ValidationError): + # self.location.write({'flowable_storage': False}) + # self.location.flowable_production_id = False + # self.location.write({'flowable_storage': False}) + # self.assertFalse(self.location.flowable_storage, "El flowable storage = False") + # + # def test_action_view_mrp_production(self): + # production = self.env['mrp.production'].create({}) + # self.location.flowable_production_id = production + # action = self.location.action_view_mrp_production() + # self.assertEqual(action['res_id'], production.id, "La acción debería mostrar + # la orden de producción correcta") diff --git a/stock_location_flowable/tests/test_stock_picking.py b/stock_location_flowable/tests/test_stock_picking.py new file mode 100644 index 000000000..63bb64592 --- /dev/null +++ b/stock_location_flowable/tests/test_stock_picking.py @@ -0,0 +1,337 @@ +# Copyright NuoBiT Solutions - Frank Cespedes +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging + +from odoo.exceptions import UserError + +from .test_common import TestCommon + +_logger = logging.getLogger(__name__) + + +class TestStockPicking(TestCommon): + @classmethod + def setUpClass(cls): + super(TestStockPicking, cls).setUpClass() + + cls.incoming_picking = cls.env["stock.picking"].create( + { + "picking_type_id": cls.picking_type_incoming_1.id, + "location_id": cls.location_flowable_1.id, + "location_dest_id": cls.location_flowable_1.id, + } + ) + + cls.outgoing_picking = cls.env["stock.picking"].create( + { + "picking_type_id": cls.picking_type_outgoing_1.id, + "location_id": cls.location_flowable_1.id, + "location_dest_id": cls.location_flowable_1.id, + } + ) + + cls.internal_picking = cls.env["stock.picking"].create( + { + "picking_type_id": cls.picking_type_internal_1.id, + "location_id": cls.location_flowable_1.id, + "location_dest_id": cls.location_flowable_2.id, + } + ) + + cls.mrp_picking = cls.env["stock.picking"].create( + { + "picking_type_id": cls.picking_type_mrp_operation_1.id, + "location_id": cls.location_flowable_1.id, + "location_dest_id": cls.location_flowable_2.id, + } + ) + + def test_receiving_one_product_in_flowable_location_incoming_picking(self): + """ + Test to ensure that receiving more than one product in a flowable location + raises an error. + + PRE: - A picking with multiple lines directed to a flowable location + ACT: - Try to validate the picking + POST: - UserError is raised stating that only one product can be received + at a flowable location + """ + # ARRANGE + moves1 = self.env["stock.move"].create( + { + "name": self.product_flowable_1.name, + "product_id": self.product_flowable_1.id, + "product_uom_qty": 5, + "product_uom": self.product_flowable_1.uom_id.id, + "picking_id": self.incoming_picking.id, + "location_id": self.incoming_picking.location_id.id, + "location_dest_id": self.incoming_picking.location_dest_id.id, + } + ) + + moves2 = self.env["stock.move"].create( + { + "name": self.product_flowable_2.name, + "product_id": self.product_flowable_2.id, + "product_uom_qty": 10, + "product_uom": self.product_flowable_2.uom_id.id, + "picking_id": self.incoming_picking.id, + "location_id": self.incoming_picking.location_id.id, + "location_dest_id": self.incoming_picking.location_dest_id.id, + } + ) + + self.incoming_picking.action_confirm() + + lot_1 = self.env["stock.production.lot"].create( + { + "name": "TEST LMP-0001", + "product_id": self.product_flowable_1.id, + } + ) + + lot_ch4 = self.env["stock.production.lot"].create( + { + "name": "TEST LMP-0002", + "product_id": self.product_flowable_2.id, + } + ) + + self.incoming_picking.move_line_ids = self.env["stock.move.line"].create( + { + "move_id": moves1.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot_1.id, + "qty_done": 5, + "location_id": self.incoming_picking.location_id.id, + "location_dest_id": self.incoming_picking.location_dest_id.id, + } + ) + + self.incoming_picking.move_line_ids |= self.env["stock.move.line"].create( + { + "move_id": moves2.id, + "product_id": self.product_flowable_2.id, + "product_uom_id": self.product_flowable_2.uom_id.id, + "lot_id": lot_ch4.id, + "qty_done": 10, + "location_id": self.incoming_picking.location_id.id, + "location_dest_id": self.incoming_picking.location_dest_id.id, + } + ) + + # ACT + with self.assertRaises(UserError) as error: + self.incoming_picking.button_validate() + + # ASSERT + msg_error = ( + "You can only receive one product at location %s" + " because a manufacturing order must be generated" + " and the location will be blocked. Create a " + "partial delivery for this product %s." + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_only_allowed_product_in_incoming_picking(self): + # ARRANGE + product_zanahoria = self.env["product.product"].create( + { + "name": "Zanahoria", + "type": "product", + "uom_id": self.env.ref("uom.product_uom_unit").id, + "uom_po_id": self.env.ref("uom.product_uom_unit").id, + "tracking": "lot", + } + ) + + moves1 = self.env["stock.move"].create( + { + "name": product_zanahoria.name, + "product_id": product_zanahoria.id, + "product_uom_qty": 5, + "product_uom": product_zanahoria.uom_id.id, + "picking_id": self.incoming_picking.id, + "location_id": self.incoming_picking.location_id.id, + "location_dest_id": self.incoming_picking.location_dest_id.id, + } + ) + + self.incoming_picking.action_confirm() + + lot_zanahoria = self.env["stock.production.lot"].create( + { + "name": "TEST COD-0001", + "product_id": product_zanahoria.id, + } + ) + + self.incoming_picking.move_line_ids = self.env["stock.move.line"].create( + { + "move_id": moves1.id, + "product_id": product_zanahoria.id, + "product_uom_id": product_zanahoria.uom_id.id, + "lot_id": lot_zanahoria.id, + "qty_done": 5, + "location_id": self.incoming_picking.location_id.id, + "location_dest_id": self.incoming_picking.location_dest_id.id, + } + ) + + # ACT + with self.assertRaises(UserError) as error: + self.incoming_picking.button_validate() + + # ASSERT + msg_error = "Product %s not allowed in flowable location %s" + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_different_uom_allowed_product_in_incoming_picking(self): + # ARRANGE + product_zanahoria = self.env["product.product"].create( + { + "name": "Zanahoria", + "type": "product", + "uom_id": self.env.ref("uom.product_uom_litre").id, + "uom_po_id": self.env.ref("uom.product_uom_litre").id, + "tracking": "lot", + } + ) + + self.location_flowable_1.flowable_allowed_product_ids = [ + (4, product_zanahoria.id) + ] + + product_zanahoria.write( + { + "uom_id": self.env.ref("uom.product_uom_unit").id, + "uom_po_id": self.env.ref("uom.product_uom_unit").id, + } + ) + + moves1 = self.env["stock.move"].create( + { + "name": product_zanahoria.name, + "product_id": product_zanahoria.id, + "product_uom_qty": 5, + "product_uom": product_zanahoria.uom_id.id, + "picking_id": self.incoming_picking.id, + "location_id": self.incoming_picking.location_id.id, + "location_dest_id": self.incoming_picking.location_dest_id.id, + } + ) + + self.incoming_picking.action_confirm() + + lot_zanahoria = self.env["stock.production.lot"].create( + { + "name": "TEST COD-0001", + "product_id": product_zanahoria.id, + } + ) + + self.incoming_picking.move_line_ids = self.env["stock.move.line"].create( + { + "move_id": moves1.id, + "product_id": product_zanahoria.id, + "product_uom_id": product_zanahoria.uom_id.id, + "lot_id": lot_zanahoria.id, + "qty_done": 5, + "location_id": self.incoming_picking.location_id.id, + "location_dest_id": self.incoming_picking.location_dest_id.id, + } + ) + + # ACT + with self.assertRaises(UserError) as error: + self.incoming_picking.button_validate() + + # ASSERT + msg_error = ( + "The allowed products %s cannot have different Unit of Measure" + " than flowable location %s" + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_not_found_manufacturing_picking_type_incoming_picking(self): + # ARRANGE + moves = self.env["stock.move"].create( + { + "name": self.product_flowable_1.name, + "product_id": self.product_flowable_1.id, + "product_uom_qty": 10, + "product_uom": self.product_flowable_1.uom_id.id, + "picking_id": self.incoming_picking.id, + "location_id": self.incoming_picking.location_id.id, + "location_dest_id": self.incoming_picking.location_dest_id.id, + } + ) + + self.incoming_picking.action_confirm() + + lot_1 = self.env["stock.production.lot"].create( + { + "name": "TEST LMP-0001", + "product_id": self.product_flowable_1.id, + } + ) + + self.incoming_picking.move_line_ids = self.env["stock.move.line"].create( + { + "move_id": moves.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot_1.id, + "qty_done": 10, + "location_id": self.incoming_picking.location_id.id, + "location_dest_id": self.incoming_picking.location_dest_id.id, + } + ) + + self.incoming_picking.action_assign() + + # ACT + with self.assertRaises(UserError) as error: + self.incoming_picking.button_validate() + + # ASSERT + msg_error = ( + "Not found manufacturing picking type for flowable" + " location %s to do flowable mixing in %s" + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_successfull_picking_type_incoming_picking(self): + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_1 = self.env["stock.production.lot"].create( + { + "name": "TEST LMP-0001", + "product_id": self.product_flowable_1.id, + } + ) + + self.env["stock.move.line"].create( + { + "picking_id": self.incoming_picking.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot_1.id, + "qty_done": 10, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": self.incoming_picking.location_dest_id.id, + "company_id": self.env.company.id, + } + ) + + self.incoming_picking.action_confirm() + + # ACT & ASSERT + self.incoming_picking.button_validate() diff --git a/stock_location_flowable/tests/test_stock_picking_type.py b/stock_location_flowable/tests/test_stock_picking_type.py new file mode 100644 index 000000000..b4746e37e --- /dev/null +++ b/stock_location_flowable/tests/test_stock_picking_type.py @@ -0,0 +1,36 @@ +# Copyright NuoBiT Solutions - Frank Cespedes +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging + +from odoo.exceptions import ValidationError + +from .test_common import TestCommon + +_logger = logging.getLogger(__name__) + + +class TestStockPickingType(TestCommon): + @classmethod + def setUpClass(cls): + super(TestStockPickingType, cls).setUpClass() + + def test_unique_flowable_operation_picking_type(self): + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + # ACT + with self.assertRaises(ValidationError) as error: + self.picking_type_mrp_operation_1_2 = self.env["stock.picking.type"].create( + { + "name": "Production2 2", + "sequence_code": "SEQ-MRP 2", + "code": "mrp_operation", + "flowable_operation": True, + } + ) + + # ASSERT + msg_error = "Only one picking type can be flowable in a warehouse %s." + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) diff --git a/stock_location_flowable/views/mrp_production_views.xml b/stock_location_flowable/views/mrp_production_views.xml new file mode 100644 index 000000000..c3c25bd20 --- /dev/null +++ b/stock_location_flowable/views/mrp_production_views.xml @@ -0,0 +1,20 @@ + + + + + mrp.production.form.inherit + mrp.production + + + + + + + {'readonly': [('production_blocked', '=', True)]} + + + + diff --git a/stock_location_flowable/views/stock_location_views.xml b/stock_location_flowable/views/stock_location_views.xml new file mode 100644 index 000000000..ab4e43fb0 --- /dev/null +++ b/stock_location_flowable/views/stock_location_views.xml @@ -0,0 +1,116 @@ + + + + + stock.location.form.inherit + stock.location + + + + + + + + + + + + + + + + + + + + + + stock.location.tree.inherit + stock.location + + + + + + + + + + + + + + diff --git a/stock_location_flowable/views/stock_move_views.xml b/stock_location_flowable/views/stock_move_views.xml new file mode 100644 index 000000000..3697a362b --- /dev/null +++ b/stock_location_flowable/views/stock_move_views.xml @@ -0,0 +1,20 @@ + + + + + stock.move.line.operations.tree.inherit + stock.move.line + + + + + + + {'readonly': [('raw_production_blocked', '=', True)]} + + + + diff --git a/stock_location_flowable/views/stock_picking_type_views.xml b/stock_location_flowable/views/stock_picking_type_views.xml new file mode 100644 index 000000000..cbed9ac1e --- /dev/null +++ b/stock_location_flowable/views/stock_picking_type_views.xml @@ -0,0 +1,17 @@ + + + + + stock.picking.type + + + + + + + + diff --git a/stock_location_flowable/views/stock_picking_views.xml b/stock_location_flowable/views/stock_picking_views.xml new file mode 100644 index 000000000..7ce36b721 --- /dev/null +++ b/stock_location_flowable/views/stock_picking_views.xml @@ -0,0 +1,68 @@ + + + + + stock.picking + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 39156b0d5e8abc180ae092696db3d2922bbc83a2 Mon Sep 17 00:00:00 2001 From: ??? Date: Tue, 25 Feb 2025 13:28:01 +0100 Subject: [PATCH 02/31] [IMP] stock_location_flowable: hide buttons on flowable operation --- stock_location_flowable/README.rst | 8 ++++---- stock_location_flowable/models/mrp_production.py | 8 +++++++- stock_location_flowable/readme/CONTRIBUTORS.rst | 6 +++--- .../static/description/index.html | 16 ++++++++-------- .../views/mrp_production_views.xml | 13 +++++++++++++ 5 files changed, 35 insertions(+), 16 deletions(-) diff --git a/stock_location_flowable/README.rst b/stock_location_flowable/README.rst index 3c111884c..0178d256c 100644 --- a/stock_location_flowable/README.rst +++ b/stock_location_flowable/README.rst @@ -7,7 +7,7 @@ Stock Location Flowable !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:53b089e0074153d414bd76790674ba0bd683e87a8d33f0ed62a664e34a0dfded + !! source digest: sha256:3d931b9503a0af5135edf372e0734fd9315f8a2b5b592f4037aad7322173eef6 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png @@ -50,9 +50,9 @@ Authors Contributors ~~~~~~~~~~~~ -* `NuoBiT `_: - - * Frank Cespedes +- [NuoBiT](https://www.nuobit.com): + - Frank Cespedes + - Deniz Gallo Maintainers ~~~~~~~~~~~ diff --git a/stock_location_flowable/models/mrp_production.py b/stock_location_flowable/models/mrp_production.py index c92fcbcb7..da892b071 100644 --- a/stock_location_flowable/models/mrp_production.py +++ b/stock_location_flowable/models/mrp_production.py @@ -1,4 +1,5 @@ -# Copyright NuoBiT Solutions - Frank Cespedes +# Copyright NuoBiT - Frank Cespedes +# Copyright 2025 NuoBiT - Deniz Gallo # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) from odoo import _, api, fields, models @@ -10,6 +11,11 @@ class MrpProduction(models.Model): picking_id = fields.Many2one(comodel_name="stock.picking") production_blocked = fields.Boolean(compute="_compute_production_blocked") + production_flowable = fields.Boolean(compute="_compute_production_flowable") + + def _compute_production_flowable(self): + for rec in self: + rec.production_flowable = rec.picking_type_id.flowable_operation def _compute_production_blocked(self): for rec in self: diff --git a/stock_location_flowable/readme/CONTRIBUTORS.rst b/stock_location_flowable/readme/CONTRIBUTORS.rst index 708936c8d..5c653a8c2 100644 --- a/stock_location_flowable/readme/CONTRIBUTORS.rst +++ b/stock_location_flowable/readme/CONTRIBUTORS.rst @@ -1,3 +1,3 @@ -* `NuoBiT `_: - - * Frank Cespedes +- [NuoBiT](https://www.nuobit.com): + - Frank Cespedes + - Deniz Gallo diff --git a/stock_location_flowable/static/description/index.html b/stock_location_flowable/static/description/index.html index e27cb45d6..19c9f17ce 100644 --- a/stock_location_flowable/static/description/index.html +++ b/stock_location_flowable/static/description/index.html @@ -9,10 +9,11 @@ /* :Author: David Goodger (goodger@python.org) -:Id: $Id: html4css1.css 8954 2022-01-20 10:10:25Z milde $ +:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $ :Copyright: This stylesheet has been placed in the public domain. Default cascading style sheet for the HTML output of Docutils. +Despite the name, some widely supported CSS2 features are used. See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to customize this style sheet. @@ -275,7 +276,7 @@ margin-left: 2em ; margin-right: 2em } -pre.code .ln { color: grey; } /* line numbers */ +pre.code .ln { color: gray; } /* line numbers */ pre.code, code { background-color: #eeeeee } pre.code .comment, code .comment { color: #5C6576 } pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold } @@ -301,7 +302,7 @@ span.pre { white-space: pre } -span.problematic { +span.problematic, pre.problematic { color: red } span.section-subtitle { @@ -367,7 +368,7 @@

Stock Location Flowable

!! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:53b089e0074153d414bd76790674ba0bd683e87a8d33f0ed62a664e34a0dfded +!! source digest: sha256:3d931b9503a0af5135edf372e0734fd9315f8a2b5b592f4037aad7322173eef6 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

Beta License: AGPL-3 NuoBiT/odoo-addons

    @@ -404,10 +405,9 @@

    Authors

    Contributors

    diff --git a/stock_location_flowable/views/mrp_production_views.xml b/stock_location_flowable/views/mrp_production_views.xml index c3c25bd20..cfd170101 100644 --- a/stock_location_flowable/views/mrp_production_views.xml +++ b/stock_location_flowable/views/mrp_production_views.xml @@ -15,6 +15,19 @@ name="attrs" >{'readonly': [('production_blocked', '=', True)]} + + + + + {'invisible': [('production_flowable', '=', True)]} + + + {'invisible': [('production_flowable', '=', True)]} + From 5be47b955d68236f8c1144243ed7af5797825014 Mon Sep 17 00:00:00 2001 From: bijaya Date: Wed, 12 Mar 2025 11:14:28 +0100 Subject: [PATCH 03/31] [FIX] stock_location_flowable: Error to archive locations --- stock_location_flowable/README.rst | 3 ++- stock_location_flowable/models/stock_location.py | 3 ++- stock_location_flowable/readme/CONTRIBUTORS.rst | 1 + stock_location_flowable/static/description/index.html | 6 +++--- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/stock_location_flowable/README.rst b/stock_location_flowable/README.rst index 0178d256c..e8dc60c65 100644 --- a/stock_location_flowable/README.rst +++ b/stock_location_flowable/README.rst @@ -7,7 +7,7 @@ Stock Location Flowable !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:3d931b9503a0af5135edf372e0734fd9315f8a2b5b592f4037aad7322173eef6 + !! source digest: sha256:54ad28ddc49473710e7dd86ba1a9dfbf22c66c1f791fd97a5a03da055b977589 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png @@ -53,6 +53,7 @@ Contributors - [NuoBiT](https://www.nuobit.com): - Frank Cespedes - Deniz Gallo + - Bijaya Kumal Maintainers ~~~~~~~~~~~ diff --git a/stock_location_flowable/models/stock_location.py b/stock_location_flowable/models/stock_location.py index e143d6def..81714cf5e 100644 --- a/stock_location_flowable/models/stock_location.py +++ b/stock_location_flowable/models/stock_location.py @@ -172,6 +172,7 @@ def name_get(self): return res def write(self, vals): + res = True for rec in self: old_allowed_products = self.env["product.product"] if "flowable_allowed_product_ids" in vals and vals.get( @@ -209,7 +210,7 @@ def write(self, vals): "flowable_uom_id": False, } ) - res = super(Location, rec).write(vals) + res &= super(Location, rec).write(vals) if rec.flowable_storage: removed_product_ids = set(old_allowed_products.ids) - set( rec.flowable_allowed_product_ids.ids diff --git a/stock_location_flowable/readme/CONTRIBUTORS.rst b/stock_location_flowable/readme/CONTRIBUTORS.rst index 5c653a8c2..289f8529d 100644 --- a/stock_location_flowable/readme/CONTRIBUTORS.rst +++ b/stock_location_flowable/readme/CONTRIBUTORS.rst @@ -1,3 +1,4 @@ - [NuoBiT](https://www.nuobit.com): - Frank Cespedes - Deniz Gallo + - Bijaya Kumal diff --git a/stock_location_flowable/static/description/index.html b/stock_location_flowable/static/description/index.html index 19c9f17ce..e44b2b124 100644 --- a/stock_location_flowable/static/description/index.html +++ b/stock_location_flowable/static/description/index.html @@ -1,4 +1,3 @@ - @@ -368,7 +367,7 @@

    Stock Location Flowable

    !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:3d931b9503a0af5135edf372e0734fd9315f8a2b5b592f4037aad7322173eef6 +!! source digest: sha256:54ad28ddc49473710e7dd86ba1a9dfbf22c66c1f791fd97a5a03da055b977589 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

    Beta License: AGPL-3 NuoBiT/odoo-addons

    From 2b705d1e0487172281996f6b23425f7955d3be37 Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Tue, 17 Feb 2026 14:50:15 +0100 Subject: [PATCH 04/31] [IMP] stock_location_flowable: added uom rounding coherence dependency --- stock_location_flowable/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stock_location_flowable/__manifest__.py b/stock_location_flowable/__manifest__.py index 8c6a0c320..5868e555b 100644 --- a/stock_location_flowable/__manifest__.py +++ b/stock_location_flowable/__manifest__.py @@ -9,7 +9,7 @@ "author": "NuoBiT Solutions", "website": "https://github.com/nuobit/odoo-addons", "category": "Stock", - "depends": ["mrp"], + "depends": ["mrp", "uom_rounding_coherence"], "license": "AGPL-3", "data": [ "views/stock_location_views.xml", From 60127993c51cdc3db61f79964dc58811871ab4ac Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Tue, 17 Feb 2026 14:54:01 +0100 Subject: [PATCH 05/31] [IMP] stock_location_flowable: added tests --- stock_location_flowable/tests/__init__.py | 4 + stock_location_flowable/tests/test_common.py | 12 +- .../tests/test_mrp_production.py | 792 +++++++++++++++++- .../tests/test_stock_location.py | 580 ++++++++++++- .../tests/test_stock_move.py | 55 ++ .../tests/test_stock_move_line.py | 41 + .../tests/test_stock_picking.py | 260 +++++- .../tests/test_stock_picking_type.py | 20 +- .../tests/test_stock_quant.py | 87 ++ .../tests/test_stock_return_picking.py | 95 +++ 10 files changed, 1908 insertions(+), 38 deletions(-) create mode 100644 stock_location_flowable/tests/test_stock_move.py create mode 100644 stock_location_flowable/tests/test_stock_move_line.py create mode 100644 stock_location_flowable/tests/test_stock_quant.py create mode 100644 stock_location_flowable/tests/test_stock_return_picking.py diff --git a/stock_location_flowable/tests/__init__.py b/stock_location_flowable/tests/__init__.py index c6930b66c..5c71e8f66 100644 --- a/stock_location_flowable/tests/__init__.py +++ b/stock_location_flowable/tests/__init__.py @@ -3,3 +3,7 @@ from . import test_stock_picking from . import test_stock_picking_type from . import test_mrp_production +from . import test_stock_return_picking +from . import test_stock_quant +from . import test_stock_move +from . import test_stock_move_line diff --git a/stock_location_flowable/tests/test_common.py b/stock_location_flowable/tests/test_common.py index f8257c8c1..e0ab5ae96 100644 --- a/stock_location_flowable/tests/test_common.py +++ b/stock_location_flowable/tests/test_common.py @@ -1,4 +1,4 @@ -# Copyright NuoBiT Solutions - Frank Cespedes +# Copyright NuoBiT Solutions - Frank Cespedes # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import logging @@ -101,6 +101,14 @@ def setUpClass(cls): } ) + cls.flowable_sequence = cls.env["ir.sequence"].create( + { + "name": "Test Flowable Sequence", + "code": "test.flowable.sequence", + "company_id": cls.env.company.id, + } + ) + cls.location_flowable_2 = cls.env["stock.location"].create( { "name": "LocationFlowable2", @@ -111,7 +119,7 @@ def setUpClass(cls): "flowable_uom_id": cls.env.ref("uom.product_uom_litre").id, "flowable_allowed_product_ids": [(4, cls.product_flowable_1.id)], "flowable_create_lots": True, - "flowable_sequence_id": cls.env.ref("stock.sequence_tracking").id, + "flowable_sequence_id": cls.flowable_sequence.id, } ) diff --git a/stock_location_flowable/tests/test_mrp_production.py b/stock_location_flowable/tests/test_mrp_production.py index d3deff21d..d628f239a 100644 --- a/stock_location_flowable/tests/test_mrp_production.py +++ b/stock_location_flowable/tests/test_mrp_production.py @@ -1,9 +1,11 @@ -# Copyright NuoBiT Solutions - Frank Cespedes +# Copyright NuoBiT Solutions - Frank Cespedes +# Copyright 2026 NuoBiT Solutions - Eric Antones # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import logging -from odoo.exceptions import UserError +from odoo.exceptions import UserError, ValidationError +from odoo.tools import float_compare, float_round from .test_common import TestCommon @@ -41,3 +43,789 @@ def test_block_new_production_flowable_location_by_outgoing_picking(self): ) msg_error = self.get_error_message_regex(msg_error) self.assertRegex(error.exception.args[0], msg_error) + + def _create_flowable_picking_and_validate(self, location, product, lot, qty): + """Helper to create and validate an incoming picking to a flowable location.""" + return self._receive_stock_at_location(location, product, lot, qty) + + def _receive_stock_at_location( + self, location, product, lot, qty, picking_type=None + ): + """Helper to create and validate an incoming picking to any location.""" + if picking_type is None: + picking_type = self.picking_type_incoming_1 + picking = self.env["stock.picking"].create( + { + "picking_type_id": picking_type.id, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": location.id, + } + ) + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": product.id, + "product_uom_id": product.uom_id.id, + "lot_id": lot.id, + "qty_done": qty, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": location.id, + "company_id": self.env.company.id, + } + ) + picking.button_validate() + return picking + + def _find_flowable_production(self, location): + """Helper to find the latest flowable MO for a location.""" + return self.env["mrp.production"].search( + [ + ("picking_type_id", "=", self.picking_type_mrp_operation_1.id), + ("location_dest_id", "=", location.id), + ], + order="id desc", + limit=1, + ) + + def _get_location_quants(self, location, product): + """Helper to get all quants at a location for a product.""" + return self.env["stock.quant"].search( + [ + ("location_id", "=", location.id), + ("product_id", "=", product.id), + ] + ) + + def _seed_flowable_location(self, location, product, lot, qty): + """Receive initial stock at a flowable location and complete the resulting MO. + + This simulates how a user would put initial stock into a flowable + location: receiving via an incoming picking, which triggers the + creation of a mixing MO, and then completing that MO so the + location is unblocked and ready for the actual test. + """ + self._receive_stock_at_location(location, product, lot, qty) + production = self._find_flowable_production(location) + if production: + production.button_mark_done() + + def test_flowable_mixing_produces_single_positive_quant(self): + """ + Test that after completing a mixing MO, the flowable location has + exactly one quant with positive quantity (the mixed lot). + + PRE: - A flowable location with an initial lot quant (100 L) + ACT: - Receive 50 L at the flowable location (creates mixing MO) + - Complete the mixing MO + POST: - Exactly one quant with positive quantity remains + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_initial = self.env["stock.production.lot"].create( + { + "name": "TEST-INITIAL-LOT", + "product_id": self.product_flowable_1.id, + } + ) + self._seed_flowable_location( + self.location_flowable_1, self.product_flowable_1, lot_initial, 100 + ) + + lot_new = self.env["stock.production.lot"].create( + { + "name": "TEST-NEW-LOT", + "product_id": self.product_flowable_1.id, + } + ) + + # ACT + self._create_flowable_picking_and_validate( + self.location_flowable_1, self.product_flowable_1, lot_new, 50 + ) + production = self._find_flowable_production(self.location_flowable_1) + self.assertTrue(production, "Mixing MO should have been created") + production.button_mark_done() + + # ASSERT + remaining_quants = self._get_location_quants( + self.location_flowable_1, self.product_flowable_1 + ) + positive_quants = remaining_quants.filtered(lambda q: q.quantity > 0) + self.assertEqual( + len(positive_quants), + 1, + "Only one quant with positive quantity should remain (the mixed lot)", + ) + + def test_flowable_no_accumulated_error_after_multiple_mixing_cycles(self): + """ + Test that multiple mixing cycles produce a clean single quant. + + PRE: - A flowable location (capacity 1000 L) + ACT: - Perform 5 sequential mixing cycles with varying quantities + POST: - Only 1 quant with positive quantity exists + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + quantities = [50, 30, 45, 25, 20] + + for i, qty in enumerate(quantities): + lot = self.env["stock.production.lot"].create( + { + "name": f"TEST-MULTI-LOT-{i}", + "product_id": self.product_flowable_1.id, + } + ) + + # ACT + self._create_flowable_picking_and_validate( + self.location_flowable_1, self.product_flowable_1, lot, qty + ) + production = self._find_flowable_production(self.location_flowable_1) + self.assertTrue(production, f"Mixing MO should be created on cycle {i + 1}") + production.button_mark_done() + + # ASSERT + remaining_quants = self._get_location_quants( + self.location_flowable_1, self.product_flowable_1 + ) + positive_quants = remaining_quants.filtered(lambda q: q.quantity > 0) + self.assertEqual( + len(positive_quants), + 1, + "Only one quant should remain after multiple mixing cycles", + ) + + def test_flowable_old_quants_go_to_exact_zero(self): + """ + Test that when a mixing MO consumes old lot quants, they go to + exactly 0.0 (IEEE 754 exact) — not a near-zero residual. + + This verifies that the raw move lines use the exact quant quantities + (no float_round on individual consumption), so each quant is + decremented by exactly its own value → result is 0.0. + + PRE: - A flowable location with 1 lot at 100 L + ACT: - Receive 50 L (creates mixing MO, but don't complete it yet) + - Check old quant after MO raw moves are processed + POST: - Old lot quant quantity is exactly 0.0 (or cleaned up) + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_initial = self.env["stock.production.lot"].create( + { + "name": "TEST-EXACT-ZERO-OLD", + "product_id": self.product_flowable_1.id, + } + ) + self._seed_flowable_location( + self.location_flowable_1, self.product_flowable_1, lot_initial, 100 + ) + + lot_new = self.env["stock.production.lot"].create( + { + "name": "TEST-EXACT-ZERO-NEW", + "product_id": self.product_flowable_1.id, + } + ) + + # ACT + self._create_flowable_picking_and_validate( + self.location_flowable_1, self.product_flowable_1, lot_new, 50 + ) + production = self._find_flowable_production(self.location_flowable_1) + production.button_mark_done() + + # ASSERT: old lot quant should not exist or be exactly 0 + old_quant = self.env["stock.quant"].search( + [ + ("location_id", "=", self.location_flowable_1.id), + ("product_id", "=", self.product_flowable_1.id), + ("lot_id", "=", lot_initial.id), + ] + ) + if old_quant: + self.assertEqual( + old_quant.quantity, + 0.0, + "Old lot quant should be exactly 0.0, not a near-zero residual", + ) + + def test_flowable_mixing_with_fractional_quantities(self): + """ + Test that mixing works correctly with fractional quantities + that could introduce floating point errors (e.g., from UoM conversions). + + PRE: - A flowable location with a fractional lot quantity (33.333 L) + ACT: - Receive 16.667 L at the flowable location (creates mixing MO) + - Complete the mixing MO + POST: - Only 1 quant with positive quantity exists + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_initial = self.env["stock.production.lot"].create( + { + "name": "TEST-FRAC-INITIAL", + "product_id": self.product_flowable_1.id, + } + ) + self._seed_flowable_location( + self.location_flowable_1, self.product_flowable_1, lot_initial, 33.333 + ) + + lot_new = self.env["stock.production.lot"].create( + { + "name": "TEST-FRAC-NEW", + "product_id": self.product_flowable_1.id, + } + ) + + # ACT + self._create_flowable_picking_and_validate( + self.location_flowable_1, self.product_flowable_1, lot_new, 16.667 + ) + production = self._find_flowable_production(self.location_flowable_1) + production.button_mark_done() + + # ASSERT + remaining_quants = self._get_location_quants( + self.location_flowable_1, self.product_flowable_1 + ) + positive_quants = remaining_quants.filtered(lambda q: q.quantity > 0) + self.assertEqual(len(positive_quants), 1) + + def test_flowable_mixing_does_not_affect_non_flowable_quants(self): + """ + Test that completing a mixing MO at a flowable location does not + affect quants at other (non-flowable) locations. + + PRE: - A flowable location with stock + - A non-flowable location with stock of the same product + ACT: - Complete a mixing MO at the flowable location + POST: - Non-flowable location quants are unaffected + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_initial = self.env["stock.production.lot"].create( + { + "name": "TEST-NONFLO-INITIAL", + "product_id": self.product_flowable_1.id, + } + ) + self._seed_flowable_location( + self.location_flowable_1, self.product_flowable_1, lot_initial, 100 + ) + + # Stock at non-flowable location + lot_other = self.env["stock.production.lot"].create( + { + "name": "TEST-NONFLO-OTHER", + "product_id": self.product_flowable_1.id, + } + ) + self._receive_stock_at_location( + self.location_1, self.product_flowable_1, lot_other, 200 + ) + + lot_new = self.env["stock.production.lot"].create( + { + "name": "TEST-NONFLO-NEW", + "product_id": self.product_flowable_1.id, + } + ) + + # ACT + self._create_flowable_picking_and_validate( + self.location_flowable_1, self.product_flowable_1, lot_new, 50 + ) + production = self._find_flowable_production(self.location_flowable_1) + production.button_mark_done() + + # ASSERT: non-flowable location quant is untouched + other_quant = self.env["stock.quant"].search( + [ + ("location_id", "=", self.location_1.id), + ("product_id", "=", self.product_flowable_1.id), + ("lot_id", "=", lot_other.id), + ] + ) + self.assertEqual( + other_quant.quantity, + 200, + "Non-flowable location quant should not be affected by mixing MO", + ) + + def test_flowable_qty_producing_is_rounded(self): + """ + Test that the mixing MO's qty_producing is properly rounded to the + product's UoM rounding, so that _post_inventory produces a clean + rounded finished quantity. + + PRE: - A flowable location with stock + ACT: - Receive new stock (creates mixing MO) + POST: - MO's qty_producing == float_round(sum(quants), uom_rounding) + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + rounding = self.product_flowable_1.uom_id.rounding + + lot_initial = self.env["stock.production.lot"].create( + { + "name": "TEST-ROUND-INITIAL", + "product_id": self.product_flowable_1.id, + } + ) + self._seed_flowable_location( + self.location_flowable_1, self.product_flowable_1, lot_initial, 100 + ) + + lot_new = self.env["stock.production.lot"].create( + { + "name": "TEST-ROUND-NEW", + "product_id": self.product_flowable_1.id, + } + ) + + # ACT + self._create_flowable_picking_and_validate( + self.location_flowable_1, self.product_flowable_1, lot_new, 50 + ) + production = self._find_flowable_production(self.location_flowable_1) + + # ASSERT: qty_producing is properly rounded + expected_qty = float_round(150.0, precision_rounding=rounding) + self.assertEqual( + production.qty_producing, + expected_qty, + f"qty_producing should be float_round({150.0}, rounding={rounding})" + f" = {expected_qty}, got {production.qty_producing}", + ) + + def test_flowable_mixing_with_custom_uom_rounding(self): + """ + Test the mixing process with a custom UoM that has fine rounding + (0.001), simulating the customer's Litro(s) O2 configuration. + + Also tests with quantities that result from a Kg→Litro conversion + (ratio 1.141) to exercise realistic float arithmetic. + + PRE: - Custom UoM category with Litro O2 (rounding=0.001) + - Product configured with this UoM + - Flowable location configured with this UoM + - Initial quant with a Kg-converted value + - Decimal precision set to 5 (accommodates UoM rounding) + ACT: - Receive new stock with another Kg-converted quantity + - Complete the mixing MO + POST: - Final stock is correct within UoM precision + """ + # ARRANGE: Increase decimal precision to accommodate UoM rounding (0.001) + dp = self.env.ref("product.decimal_product_uom") + dp.digits = 5 + + # Custom UoM with fine rounding (like customer's Litro O2) + uom_category_o2 = self.env["uom.category"].create({"name": "Test Volumen O2"}) + uom_litro_o2 = self.env["uom.uom"].create( + { + "name": "Test Litro O2", + "category_id": uom_category_o2.id, + "uom_type": "reference", + "rounding": 0.001, + } + ) + rounding = uom_litro_o2.rounding # 0.001 + + product_o2 = self.env["product.product"].create( + { + "name": "Test Oxigeno Liquido", + "type": "product", + "uom_id": uom_litro_o2.id, + "uom_po_id": uom_litro_o2.id, + "tracking": "lot", + } + ) + + location_cistern = self.env["stock.location"].create( + { + "name": "Test Cisterna O2", + "usage": "internal", + "location_id": self.env.ref( + "stock.stock_location_locations_partner" + ).id, + "flowable_storage": True, + "flowable_capacity": 5000, + "flowable_uom_id": uom_litro_o2.id, + "flowable_allowed_product_ids": [(4, product_o2.id)], + "flowable_create_lots": True, + "flowable_sequence_id": self.env.ref("stock.sequence_tracking").id, + } + ) + + # Simulate quant from a Kg→Litro conversion: 657.93 Kg / 1.141 + # = 576.6257668712... L → rounded to 576.626 + kg_delivery_1 = 657.93 + litres_1 = float_round( + kg_delivery_1 / 1.141, precision_rounding=rounding + ) # 576.626 + + lot_initial = self.env["stock.production.lot"].create( + { + "name": "TEST-O2-INITIAL", + "product_id": product_o2.id, + } + ) + self.picking_type_mrp_operation_1.flowable_operation = True + + self._seed_flowable_location( + location_cistern, product_o2, lot_initial, litres_1 + ) + + # Second delivery: 583.21 Kg / 1.141 = 511.1393... → 511.139 + kg_delivery_2 = 583.21 + litres_2 = float_round( + kg_delivery_2 / 1.141, precision_rounding=rounding + ) # 511.139 + + lot_new = self.env["stock.production.lot"].create( + { + "name": "TEST-O2-NEW", + "product_id": product_o2.id, + } + ) + + # ACT: Receive at the cistern + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_incoming_1.id, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": location_cistern.id, + } + ) + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": product_o2.id, + "product_uom_id": uom_litro_o2.id, + "lot_id": lot_new.id, + "qty_done": litres_2, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": location_cistern.id, + "company_id": self.env.company.id, + } + ) + picking.button_validate() + + # Find and complete the mixing MO + production = self.env["mrp.production"].search( + [ + ("picking_type_id", "=", self.picking_type_mrp_operation_1.id), + ("location_dest_id", "=", location_cistern.id), + ], + order="id desc", + limit=1, + ) + self.assertTrue(production, "Mixing MO should have been created") + production.button_mark_done() + + # ASSERT + remaining = self.env["stock.quant"].search( + [ + ("location_id", "=", location_cistern.id), + ("product_id", "=", product_o2.id), + ] + ) + positive_quants = remaining.filtered(lambda q: q.quantity > 0) + self.assertEqual( + len(positive_quants), + 1, + "Only one positive quant should remain (the mixed lot)", + ) + expected_total = litres_1 + litres_2 + self.assertEqual( + float_compare( + positive_quants.quantity, + expected_total, + precision_rounding=rounding, + ), + 0, + f"Final stock {positive_quants.quantity} should equal total" + f" {expected_total} within UoM rounding {rounding}", + ) + + def test_flowable_mixing_multiple_cycles_fine_rounding(self): + """ + Test multiple mixing cycles with fine UoM rounding (0.001) and + Kg→Litro converted quantities, verifying no error accumulation. + + Simulates 5 sequential deliveries converted from Kg O2 to Litro O2 + (ratio 1.141), ensuring the flowable location stays clean. + + PRE: - Custom UoM with rounding=0.001 + - Decimal precision set to 5 (accommodates UoM rounding) + ACT: - 5 sequential receive → mix → complete cycles + POST: - Only 1 quant with positive quantity remains + - Final stock within UoM precision of sum of deliveries + """ + # ARRANGE: Increase decimal precision to accommodate UoM rounding (0.001) + dp = self.env.ref("product.decimal_product_uom") + dp.digits = 5 + + self.picking_type_mrp_operation_1.flowable_operation = True + + uom_category_o2 = self.env["uom.category"].create( + {"name": "Test Volumen O2 Multi"} + ) + uom_litro_o2 = self.env["uom.uom"].create( + { + "name": "Test Litro O2 Multi", + "category_id": uom_category_o2.id, + "uom_type": "reference", + "rounding": 0.001, + } + ) + rounding = uom_litro_o2.rounding + + product_o2 = self.env["product.product"].create( + { + "name": "Test Oxigeno Multi", + "type": "product", + "uom_id": uom_litro_o2.id, + "uom_po_id": uom_litro_o2.id, + "tracking": "lot", + } + ) + + location_cistern = self.env["stock.location"].create( + { + "name": "Test Cisterna O2 Multi", + "usage": "internal", + "location_id": self.env.ref( + "stock.stock_location_locations_partner" + ).id, + "flowable_storage": True, + "flowable_capacity": 10000, + "flowable_uom_id": uom_litro_o2.id, + "flowable_allowed_product_ids": [(4, product_o2.id)], + "flowable_create_lots": True, + "flowable_sequence_id": self.env.ref("stock.sequence_tracking").id, + } + ) + + # Simulate 5 deliveries in Kg, converted to Litres + kg_deliveries = [657.93, 583.21, 492.84, 701.23, 515.67] + + for i, kg in enumerate(kg_deliveries): + litres = float_round(kg / 1.141, precision_rounding=rounding) + + lot = self.env["stock.production.lot"].create( + { + "name": f"TEST-O2-MULTI-{i}", + "product_id": product_o2.id, + } + ) + + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_incoming_1.id, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": location_cistern.id, + } + ) + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": product_o2.id, + "product_uom_id": uom_litro_o2.id, + "lot_id": lot.id, + "qty_done": litres, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": location_cistern.id, + "company_id": self.env.company.id, + } + ) + picking.button_validate() + + production = self.env["mrp.production"].search( + [ + ( + "picking_type_id", + "=", + self.picking_type_mrp_operation_1.id, + ), + ("location_dest_id", "=", location_cistern.id), + ], + order="id desc", + limit=1, + ) + self.assertTrue( + production, f"Mixing MO should be created on delivery {i + 1}" + ) + production.button_mark_done() + + # ASSERT + remaining = self.env["stock.quant"].search( + [ + ("location_id", "=", location_cistern.id), + ("product_id", "=", product_o2.id), + ] + ) + positive_quants = remaining.filtered(lambda q: q.quantity > 0) + self.assertEqual( + len(positive_quants), + 1, + "Only one positive quant should remain after 5 cycles", + ) + expected_total = sum( + float_round(kg / 1.141, precision_rounding=rounding) for kg in kg_deliveries + ) + self.assertEqual( + float_compare( + positive_quants.quantity, + expected_total, + precision_rounding=rounding, + ), + 0, + f"Final stock {positive_quants.quantity} should equal total" + f" {expected_total} within UoM rounding {rounding}", + ) + + def test_production_flowable_computed(self): + """ + Test that production_flowable is True when the picking type is flowable. + + PRE: - A flowable MO created from a picking + ACT: - Read production_flowable + POST: - production_flowable is True + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + self.incoming_picking.button_validate() + + production = self._find_flowable_production(self.location_flowable_1) + + # ACT & ASSERT + self.assertTrue(production.production_flowable) + + def test_production_flowable_false_for_non_flowable_type(self): + """ + Test that production_flowable is False when picking type is not flowable. + + PRE: - A standard MO (non-flowable picking type) + ACT: - Read production_flowable + POST: - production_flowable is False + """ + # ARRANGE + production = self.env["mrp.production"].create( + { + "product_id": self.product_flowable_1.id, + "product_qty": 10, + "product_uom_id": self.product_flowable_1.uom_id.id, + "picking_type_id": self.picking_type_mrp_operation_1.id, + "location_src_id": self.location_flowable_1.id, + "location_dest_id": self.location_flowable_1.id, + } + ) + + # ACT & ASSERT + self.assertFalse(production.production_flowable) + + def test_production_blocked_computed(self): + """ + Test that production_blocked is True when a location references the MO. + + PRE: - A flowable location with a production linked + ACT: - Read production_blocked + POST: - production_blocked is True + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + self.incoming_picking.button_validate() + + production = self._find_flowable_production(self.location_flowable_1) + + # ACT & ASSERT + self.assertTrue(production.production_blocked) + + def test_cancel_production_with_picking_raises_error(self): + """ + Test that cancelling a production with a picking associated raises + an error. + + PRE: - A flowable MO with a picking_id set + ACT: - Try to cancel the MO + POST: - An error is raised because the picking is associated + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + self.incoming_picking.button_validate() + + production = self._find_flowable_production(self.location_flowable_1) + self.assertTrue(production.picking_id) + + # ACT & ASSERT + # The cancel flow triggers stock_move.write which also blocks + # modification, so we expect either UserError or ValidationError + with self.assertRaises(Exception) as error: + production.action_cancel() + + # Verify the error is related to the mixing being in progress + self.assertIn("mixing is in progress", str(error.exception)) + + def test_write_to_close_production_with_picking_raises_error(self): + """ + Test that modifying a to_close production with a picking raises + a ValidationError. + + PRE: - A flowable MO in to_close state with a picking_id + ACT: - Try to write to the MO + POST: - ValidationError is raised + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + self.incoming_picking.button_validate() + + production = self._find_flowable_production(self.location_flowable_1) + self.assertTrue(production.picking_id) + self.assertEqual(production.state, "to_close") + + # ACT & ASSERT + with self.assertRaises(ValidationError) as error: + production.write({"product_qty": 999}) + + msg_error = ( + "You cannot modify a mix production with a picking associated." + " The mixing is in progress." + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_production_without_bom_allowed_for_flowable(self): + """ + Test that a flowable production can be created without a Bill of + Materials because _check_production_lines is bypassed. + + PRE: - A flowable mrp_operation picking type + ACT: - Create a production without BoM + POST: - Production is created successfully + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + # ACT + production = self.env["mrp.production"].create( + { + "product_id": self.product_flowable_1.id, + "product_qty": 10, + "product_uom_id": self.product_flowable_1.uom_id.id, + "picking_type_id": self.picking_type_mrp_operation_1.id, + "location_src_id": self.location_flowable_1.id, + "location_dest_id": self.location_flowable_1.id, + } + ) + + # ASSERT + self.assertTrue(production) diff --git a/stock_location_flowable/tests/test_stock_location.py b/stock_location_flowable/tests/test_stock_location.py index ff53a7317..5fd4e3a72 100644 --- a/stock_location_flowable/tests/test_stock_location.py +++ b/stock_location_flowable/tests/test_stock_location.py @@ -1,9 +1,10 @@ -# Copyright NuoBiT Solutions - Frank Cespedes +# Copyright NuoBiT Solutions - Frank Cespedes +# Copyright 2026 NuoBiT Solutions - Eric Antones # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import logging -from odoo.exceptions import ValidationError +from odoo.exceptions import UserError, ValidationError from .test_common import TestCommon @@ -309,33 +310,548 @@ def test_check_flowable_sequence_id(self): msg_error = self.get_error_message_regex(msg_error) self.assertRegex(error.exception.args[0], msg_error) - # Proposals for possible future tests to implement - # - # def test_flowable_capacity_constraints(self): - # with self.assertRaises(ValidationError): - # self.location.flowable_capacity = 50 - # self.location.flowable_capacity = 150 - # self.assertEqual(self.location.flowable_capacity, 150, "La capacity debe - # actualizarse good") - # - # def test_product_uom_constraints(self): - # self.location.write({ - # 'flowable_allowed_product_ids': [(4, self.product_flowable_1.id)], - # 'flowable_uom_id': self.env.ref('uom.product_uom_dozen').id - # }) - # with self.assertRaises(ValidationError): - # self.location._check_flowable_uom_id() - # - # def test_write_method(self): - # with self.assertRaises(ValidationError): - # self.location.write({'flowable_storage': False}) - # self.location.flowable_production_id = False - # self.location.write({'flowable_storage': False}) - # self.assertFalse(self.location.flowable_storage, "El flowable storage = False") - # - # def test_action_view_mrp_production(self): - # production = self.env['mrp.production'].create({}) - # self.location.flowable_production_id = production - # action = self.location.action_view_mrp_production() - # self.assertEqual(action['res_id'], production.id, "La acción debería mostrar - # la orden de producción correcta") + def test_disable_flowable_storage_clears_fields(self): + """ + Test that disabling flowable_storage on a location clears all + flowable-related fields. + + PRE: - A fully configured flowable location + ACT: - Set flowable_storage to False + POST: - All flowable fields are cleared + """ + # ARRANGE + self.product_flowable_1.tracking = "lot" + self.location_flowable_bcn_1.write( + { + "flowable_storage": True, + "flowable_capacity": 100.0, + "flowable_uom_id": self.uom_litre.id, + "flowable_allowed_product_ids": [(4, self.product_flowable_1.id)], + } + ) + + # ACT + self.location_flowable_bcn_1.write({"flowable_storage": False}) + + # ASSERT + self.assertFalse(self.location_flowable_bcn_1.flowable_storage) + self.assertEqual(self.location_flowable_bcn_1.flowable_capacity, 0) + self.assertFalse(self.location_flowable_bcn_1.flowable_uom_id) + self.assertFalse(self.location_flowable_bcn_1.flowable_sequence_id) + + def test_cannot_disable_flowable_on_blocked_location(self): + """ + Test that disabling flowable_storage on a blocked location + (one with a production linked) raises an error. + + PRE: - A flowable location with a production linked + ACT: - Attempt to set flowable_storage to False + POST: - ValidationError is raised + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + self.incoming_picking.button_validate() + self.assertTrue(self.location_flowable_1.flowable_blocked) + + # ACT & ASSERT + with self.assertRaises(ValidationError) as error: + self.location_flowable_1.write({"flowable_storage": False}) + + msg_error = "You cannot disable flowable storage from a blocked location." + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_flowable_blocked_popover(self): + """ + Test that the flowable_blocked_popover computed field returns + a JSON string with the expected warning message. + + PRE: - A flowable location + ACT: - Read flowable_blocked_popover + POST: - Contains expected title and message + """ + # ACT & ASSERT + popover = self.location_flowable_1.flowable_blocked_popover + self.assertIn("Flowable Location Warning", popover) + self.assertIn("manufacturing order", popover) + + def test_flowable_capacity_occupied(self): + """ + Test that flowable_capacity_occupied computes the sum of quant quantities. + + PRE: - A flowable location with stock + ACT: - Receive stock and check capacity_occupied + POST: - capacity_occupied reflects the stock + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + product = self.location_flowable_1.flowable_allowed_product_ids[0] + + lot = self.env["stock.production.lot"].create( + { + "name": "TEST-CAP-LOT", + "product_id": product.id, + } + ) + + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_incoming_1.id, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": self.location_flowable_1.id, + } + ) + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": product.id, + "product_uom_id": product.uom_id.id, + "lot_id": lot.id, + "qty_done": 100, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": self.location_flowable_1.id, + "company_id": self.env.company.id, + } + ) + picking.button_validate() + + # ACT + production = self.env["mrp.production"].search( + [ + ("picking_type_id", "=", self.picking_type_mrp_operation_1.id), + ("location_dest_id", "=", self.location_flowable_1.id), + ], + order="id desc", + limit=1, + ) + production.button_mark_done() + + # ASSERT + self.location_flowable_1.invalidate_cache() + self.assertGreater(self.location_flowable_1.flowable_capacity_occupied, 0) + + def test_flowable_percentage_occupied(self): + """ + Test that flowable_percentage_occupied is calculated correctly. + + PRE: - A flowable location with capacity=1000 and no stock + ACT: - Read percentage_occupied + POST: - percentage is 0 when empty + """ + # ACT & ASSERT + self.assertEqual(self.location_flowable_1.flowable_percentage_occupied, 0) + + def test_flowable_percentage_zero_capacity(self): + """ + Test that flowable_percentage_occupied returns 0 when capacity is 0. + + PRE: - A non-flowable location with capacity=0 + ACT: - Read flowable_percentage_occupied + POST: - Returns 0 (no division by zero) + """ + # ACT & ASSERT + self.assertEqual(self.location_flowable_bcn_1.flowable_percentage_occupied, 0) + + def test_action_view_mrp_production(self): + """ + Test that the action_view_mrp_production method returns an action + pointing to the linked production. + + PRE: - A flowable location with a production linked + ACT: - Call action_view_mrp_production + POST: - Action res_id matches the production + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + self.incoming_picking.button_validate() + + production = self.location_flowable_1.flowable_production_id + + # ACT + action = self.location_flowable_1.action_view_mrp_production() + + # ASSERT + self.assertEqual(action["res_id"], production.id) + self.assertEqual(action["res_model"], "mrp.production") + + def test_complete_name_blocked(self): + """ + Test that the complete_name of a blocked flowable location + includes '[Blocked]'. + + PRE: - A flowable location that is blocked + ACT: - Read complete_name + POST: - Contains 'Blocked' + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + self.incoming_picking.button_validate() + + # ACT & ASSERT + self.assertIn("Blocked", self.location_flowable_1.complete_name) + + def test_name_get_blocked(self): + """ + Test that name_get for a blocked flowable location includes '[Blocked]'. + + PRE: - A flowable location that is blocked + ACT: - Call name_get + POST: - Display name contains '[Blocked]' + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + self.incoming_picking.button_validate() + + # ACT + result = self.location_flowable_1.name_get() + + # ASSERT + self.assertIn("[Blocked]", result[0][1]) + + def test_name_get_not_blocked(self): + """ + Test that name_get for a non-blocked flowable location does NOT + include '[Blocked]'. + + PRE: - A flowable location that is not blocked + ACT: - Call name_get + POST: - Display name does not contain '[Blocked]' + """ + # ACT + result = self.location_flowable_1.name_get() + + # ASSERT + self.assertNotIn("[Blocked]", result[0][1]) + + def test_cannot_remove_stored_product_from_allowed(self): + """ + Test that removing a product from flowable_allowed_product_ids + raises an error when stock of that product exists. + + PRE: - A flowable location with stock of a product + ACT: - Try to remove that product from allowed list + POST: - UserError is raised + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + product = self.location_flowable_1.flowable_allowed_product_ids[0] + + lot = self.env["stock.production.lot"].create( + { + "name": "TEST-REMOVE-LOT", + "product_id": product.id, + } + ) + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_incoming_1.id, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": self.location_flowable_1.id, + } + ) + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": product.id, + "product_uom_id": product.uom_id.id, + "lot_id": lot.id, + "qty_done": 50, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": self.location_flowable_1.id, + "company_id": self.env.company.id, + } + ) + picking.button_validate() + + production = self.env["mrp.production"].search( + [ + ("picking_type_id", "=", self.picking_type_mrp_operation_1.id), + ("location_dest_id", "=", self.location_flowable_1.id), + ], + order="id desc", + limit=1, + ) + production.button_mark_done() + + # ACT & ASSERT + with self.assertRaises(UserError) as error: + self.location_flowable_1.write( + { + "flowable_allowed_product_ids": [ + (3, product.id), + ], + } + ) + + msg_error = ( + "You cannot remove a product that is currently" " stored in this location." + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_capacity_full_raises_error(self): + """ + Test that receiving stock that fills the flowable location to its + capacity triggers the capacity constraint. + + PRE: - A flowable location with capacity 100 + ACT: - Receive 100 litres (exactly the capacity) + POST: - ValidationError is raised about location capacity being full + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + self.location_flowable_1.write({"flowable_capacity": 100}) + product = self.location_flowable_1.flowable_allowed_product_ids[0] + + lot = self.env["stock.production.lot"].create( + {"name": "TEST-FULL-LOT", "product_id": product.id} + ) + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_incoming_1.id, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": self.location_flowable_1.id, + } + ) + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": product.id, + "product_uom_id": product.uom_id.id, + "lot_id": lot.id, + "qty_done": 100, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": self.location_flowable_1.id, + "company_id": self.env.company.id, + } + ) + + # ACT & ASSERT + with self.assertRaises(ValidationError) as error: + picking.button_validate() + + msg_error = "Location capacity is full" + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_reduce_capacity_below_occupied(self): + """ + Test that reducing a flowable location's capacity below the occupied + amount is blocked. + + PRE: - A flowable location with stock (capacity_occupied > 0) + ACT: - Try to reduce capacity below the occupied amount + POST: - ValidationError is raised because occupied >= new capacity + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + product = self.location_flowable_1.flowable_allowed_product_ids[0] + + lot = self.env["stock.production.lot"].create( + {"name": "TEST-REDUCECAP-LOT", "product_id": product.id} + ) + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_incoming_1.id, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": self.location_flowable_1.id, + } + ) + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": product.id, + "product_uom_id": product.uom_id.id, + "lot_id": lot.id, + "qty_done": 100, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": self.location_flowable_1.id, + "company_id": self.env.company.id, + } + ) + picking.button_validate() + + production = self.env["mrp.production"].search( + [ + ("picking_type_id", "=", self.picking_type_mrp_operation_1.id), + ("location_dest_id", "=", self.location_flowable_1.id), + ], + order="id desc", + limit=1, + ) + production.button_mark_done() + + # ACT & ASSERT + with self.assertRaises(ValidationError): + self.location_flowable_1.write({"flowable_capacity": 50}) + + def test_change_uom_with_existing_stock(self): + """ + Test that changing flowable_uom_id when quants with a different UoM + exist at the location is blocked. + + PRE: - A flowable location with stock (quants in litres) + ACT: - Change flowable_uom_id to a different UoM + POST: - ValidationError about different unit of measure + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + product = self.location_flowable_1.flowable_allowed_product_ids[0] + + lot = self.env["stock.production.lot"].create( + {"name": "TEST-UOM-LOT", "product_id": product.id} + ) + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_incoming_1.id, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": self.location_flowable_1.id, + } + ) + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": product.id, + "product_uom_id": product.uom_id.id, + "lot_id": lot.id, + "qty_done": 50, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": self.location_flowable_1.id, + "company_id": self.env.company.id, + } + ) + picking.button_validate() + + production = self.env["mrp.production"].search( + [ + ("picking_type_id", "=", self.picking_type_mrp_operation_1.id), + ("location_dest_id", "=", self.location_flowable_1.id), + ], + order="id desc", + limit=1, + ) + production.button_mark_done() + + # Create a new product with units UoM to make the change valid + # for the allowed products constraint, isolating _check_flowable_uom_id + product_unit = self.env["product.product"].create( + { + "name": "Test Product Units", + "type": "product", + "uom_id": self.uom_unit.id, + "uom_po_id": self.uom_unit.id, + "tracking": "lot", + } + ) + + # ACT & ASSERT + with self.assertRaises(ValidationError) as error: + self.location_flowable_1.write( + { + "flowable_uom_id": self.uom_unit.id, + "flowable_allowed_product_ids": [ + (6, 0, [product_unit.id]), + ], + } + ) + + msg_error = "You have stock movements with different unit of measure" + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_convert_location_with_unmixed_products(self): + """ + Test that enabling flowable_storage on a location that already has + multiple positive quants of the same product is blocked. + + PRE: - A non-flowable location with two lots of the same product + ACT: - Try to enable flowable_storage + POST: - UserError about unmixed products + """ + # ARRANGE — add two lots via inventory adjustments + product = self.location_flowable_1.flowable_allowed_product_ids[0] + + lot_1 = self.env["stock.production.lot"].create( + {"name": "UNMIXED-LOT-1", "product_id": product.id} + ) + lot_2 = self.env["stock.production.lot"].create( + {"name": "UNMIXED-LOT-2", "product_id": product.id} + ) + + inventory = self.env["stock.inventory"].create({"name": "Add unmixed stock"}) + inventory.action_start() + self.env["stock.inventory.line"].create( + [ + { + "inventory_id": inventory.id, + "product_id": product.id, + "product_uom_id": product.uom_id.id, + "location_id": self.location_1.id, + "prod_lot_id": lot_1.id, + "product_qty": 50, + }, + { + "inventory_id": inventory.id, + "product_id": product.id, + "product_uom_id": product.uom_id.id, + "location_id": self.location_1.id, + "prod_lot_id": lot_2.id, + "product_qty": 30, + }, + ] + ) + inventory.action_validate() + + # ACT & ASSERT + with self.assertRaises(UserError) as error: + self.location_1.write( + { + "flowable_storage": True, + "flowable_capacity": 1000, + "flowable_uom_id": product.uom_id.id, + "flowable_allowed_product_ids": [(4, product.id)], + } + ) + + msg_error = ( + "You cannot convert this location into a flowable location" + " because there are unmixed products." + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_capacity_occupied_zero_for_non_flowable_location(self): + """ + Test that a non-flowable location always has + flowable_capacity_occupied = 0 even if it has stock. + + PRE: - A non-flowable location with stock + ACT: - Read flowable_capacity_occupied + POST: - Value is 0 + """ + # ARRANGE — add stock via inventory adjustment + product = self.location_flowable_1.flowable_allowed_product_ids[0] + + lot = self.env["stock.production.lot"].create( + {"name": "NONFLO-LOT", "product_id": product.id} + ) + inventory = self.env["stock.inventory"].create( + {"name": "Add stock to non-flowable"} + ) + inventory.action_start() + self.env["stock.inventory.line"].create( + { + "inventory_id": inventory.id, + "product_id": product.id, + "product_uom_id": product.uom_id.id, + "location_id": self.location_1.id, + "prod_lot_id": lot.id, + "product_qty": 200, + } + ) + inventory.action_validate() + + # ACT & ASSERT + self.location_1.invalidate_cache() + self.assertEqual(self.location_1.flowable_capacity_occupied, 0) diff --git a/stock_location_flowable/tests/test_stock_move.py b/stock_location_flowable/tests/test_stock_move.py new file mode 100644 index 000000000..0347c781a --- /dev/null +++ b/stock_location_flowable/tests/test_stock_move.py @@ -0,0 +1,55 @@ +# Copyright 2026 NuoBiT Solutions - Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging + +from odoo.exceptions import UserError + +from .test_common import TestCommon + +_logger = logging.getLogger(__name__) + + +class TestStockMove(TestCommon): + @classmethod + def setUpClass(cls): + super(TestStockMove, cls).setUpClass() + + def test_modify_in_progress_flowable_move_raises_error(self): + """ + Test that cancelling a flowable production with a picking triggers + the stock.move write guard, which prevents state changes on raw + moves of an in-progress mixing. + + When a user clicks "Cancel" on the MO, the cancel flow attempts to + change the raw moves' state, and the write override blocks it. + + PRE: - A flowable MO in to_close state with a picking + ACT: - Cancel the production (user action) + POST: - UserError is raised about mixing in progress + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + self.incoming_picking.button_validate() + + production = self.env["mrp.production"].search( + [ + ("picking_type_id", "=", self.picking_type_mrp_operation_1.id), + ("location_dest_id", "=", self.location_flowable_1.id), + ], + order="id desc", + limit=1, + ) + self.assertTrue(production.picking_id) + self.assertEqual(production.state, "to_close") + + # ACT & ASSERT + with self.assertRaises(UserError) as error: + production.action_cancel() + + msg_error = ( + "You cannot modify a production with a picking associated." + " The mixing is in progress." + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) diff --git a/stock_location_flowable/tests/test_stock_move_line.py b/stock_location_flowable/tests/test_stock_move_line.py new file mode 100644 index 000000000..de0fc5e76 --- /dev/null +++ b/stock_location_flowable/tests/test_stock_move_line.py @@ -0,0 +1,41 @@ +# Copyright 2026 NuoBiT Solutions - Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging + +from .test_common import TestCommon + +_logger = logging.getLogger(__name__) + + +class TestStockMoveLine(TestCommon): + @classmethod + def setUpClass(cls): + super(TestStockMoveLine, cls).setUpClass() + + def test_blocked_location_rejects_unrelated_production_done(self): + """ + Test that validating a picking whose destination is a blocked + flowable location raises an error. + + PRE: - A flowable location blocked by a production + ACT: - Try to validate an outgoing picking targeting that location + POST: - An error is raised about the location being blocked + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + self.incoming_picking.button_validate() + + # Location should now be blocked by the flowable production + self.assertTrue(self.location_flowable_1.flowable_blocked) + + # ACT & ASSERT + with self.assertRaises(Exception) as error: + self.outgoing_picking.button_validate() + + msg_error = ( + "The location %s is blocked. Probably you need to review" + " the pending manufacturing orders related to this location" + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) diff --git a/stock_location_flowable/tests/test_stock_picking.py b/stock_location_flowable/tests/test_stock_picking.py index 63bb64592..83cc59a30 100644 --- a/stock_location_flowable/tests/test_stock_picking.py +++ b/stock_location_flowable/tests/test_stock_picking.py @@ -1,4 +1,5 @@ -# Copyright NuoBiT Solutions - Frank Cespedes +# Copyright NuoBiT Solutions - Frank Cespedes +# Copyright 2026 NuoBiT Solutions - Eric Antones # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import logging @@ -335,3 +336,260 @@ def test_successfull_picking_type_incoming_picking(self): # ACT & ASSERT self.incoming_picking.button_validate() + + def test_action_view_mrp_production_single(self): + """ + Test that action_view_mrp_production returns a form view when there + is exactly one production linked to the picking. + + PRE: - A picking with one flowable production + ACT: - Call action_view_mrp_production + POST: - Action opens the form view with the production res_id + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_1 = self.env["stock.production.lot"].create( + { + "name": "TEST-ACTION-LOT", + "product_id": self.product_flowable_1.id, + } + ) + + self.env["stock.move.line"].create( + { + "picking_id": self.incoming_picking.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot_1.id, + "qty_done": 10, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": self.incoming_picking.location_dest_id.id, + "company_id": self.env.company.id, + } + ) + self.incoming_picking.button_validate() + + # ACT + action = self.incoming_picking.action_view_mrp_production() + + # ASSERT + self.assertEqual(action["res_model"], "mrp.production") + self.assertEqual( + action["res_id"], + self.incoming_picking.flowable_production_ids[0].id, + ) + + def test_action_view_mrp_production_multiple(self): + """ + Test that action_view_mrp_production returns a list view when there + are multiple productions linked to the picking. + + PRE: - A picking with multiple flowable productions + ACT: - Call action_view_mrp_production + POST: - Action opens a list filtered by production ids + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_1 = self.env["stock.production.lot"].create( + { + "name": "TEST-MULTI-LOT", + "product_id": self.product_flowable_1.id, + } + ) + + self.env["stock.move.line"].create( + { + "picking_id": self.incoming_picking.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot_1.id, + "qty_done": 10, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": self.incoming_picking.location_dest_id.id, + "company_id": self.env.company.id, + } + ) + self.incoming_picking.button_validate() + + # Create a second production manually linked to the same picking + self.env["mrp.production"].create( + { + "product_id": self.product_flowable_1.id, + "product_qty": 5, + "product_uom_id": self.product_flowable_1.uom_id.id, + "picking_type_id": self.picking_type_mrp_operation_1.id, + "location_src_id": self.location_flowable_1.id, + "location_dest_id": self.location_flowable_1.id, + "picking_id": self.incoming_picking.id, + } + ) + + # ACT + action = self.incoming_picking.action_view_mrp_production() + + # ASSERT + self.assertIn("domain", action) + self.assertEqual( + action["domain"], + [("id", "in", self.incoming_picking.flowable_production_ids.ids)], + ) + + def test_mrp_operation_type_without_sequence_raises_error(self): + """ + Test that a flowable mrp_operation picking type without a sequence + raises an error during picking validation. + + PRE: - A flowable mrp_operation picking type without sequence_id + ACT: - Validate a picking to a flowable location + POST: - UserError is raised about missing sequence + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + self.picking_type_mrp_operation_1.sequence_id = False + + lot_1 = self.env["stock.production.lot"].create( + { + "name": "TEST-NOSEQ-LOT", + "product_id": self.product_flowable_1.id, + } + ) + + self.env["stock.move.line"].create( + { + "picking_id": self.incoming_picking.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot_1.id, + "qty_done": 10, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": self.incoming_picking.location_dest_id.id, + "company_id": self.env.company.id, + } + ) + + # ACT & ASSERT + with self.assertRaises(UserError) as error: + self.incoming_picking.button_validate() + + msg_error = ( + "Not found sequence in manufacturing picking type %s" + " for flowable location %s" + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_non_lot_tracked_product_at_flowable_location(self): + """ + Test that receiving a product whose tracking was changed from 'lot' + to 'none' after being added to the flowable allowed products raises + an error during picking validation. + + PRE: - A product added to flowable allowed products with tracking=lot + - Product tracking changed to 'none' afterwards + ACT: - Validate an incoming picking with that product + POST: - UserError about product tracking + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + product_nolot = self.env["product.product"].create( + { + "name": "ProductNoLot", + "type": "product", + "uom_id": self.env.ref("uom.product_uom_litre").id, + "uom_po_id": self.env.ref("uom.product_uom_litre").id, + "tracking": "lot", + } + ) + self.location_flowable_1.write( + {"flowable_allowed_product_ids": [(4, product_nolot.id)]} + ) + # Change tracking after adding to allowed products (bypasses location constraint) + product_nolot.tracking = "none" + + lot = self.env["stock.production.lot"].create( + {"name": "TEST-NOLOT", "product_id": product_nolot.id} + ) + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_incoming_1.id, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": self.location_flowable_1.id, + } + ) + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": product_nolot.id, + "product_uom_id": product_nolot.uom_id.id, + "lot_id": lot.id, + "qty_done": 10, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": self.location_flowable_1.id, + "company_id": self.env.company.id, + } + ) + + # ACT & ASSERT + with self.assertRaises(UserError) as error: + picking.button_validate() + + msg_error = "Product %s must be tracked by lot" + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_auto_lot_creation_with_sequence(self): + """ + Test that receiving stock at a flowable location with + flowable_create_lots=True auto-creates a lot from the sequence. + + PRE: - location_flowable_2 has flowable_create_lots=True and + flowable_sequence_id set + ACT: - Validate an incoming picking to location_flowable_2 + POST: - The auto-created production has a lot_producing_id + different from the incoming lot + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + product = self.location_flowable_2.flowable_allowed_product_ids[0] + + lot = self.env["stock.production.lot"].create( + {"name": "TEST-AUTOLOT", "product_id": product.id} + ) + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_incoming_1.id, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": self.location_flowable_2.id, + } + ) + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": product.id, + "product_uom_id": product.uom_id.id, + "lot_id": lot.id, + "qty_done": 50, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": self.location_flowable_2.id, + "company_id": self.env.company.id, + } + ) + + # ACT + picking.button_validate() + + production = self.env["mrp.production"].search( + [ + ("picking_type_id", "=", self.picking_type_mrp_operation_1.id), + ("location_dest_id", "=", self.location_flowable_2.id), + ], + order="id desc", + limit=1, + ) + + # ASSERT + self.assertTrue(production.lot_producing_id) + self.assertNotEqual(production.lot_producing_id, lot) diff --git a/stock_location_flowable/tests/test_stock_picking_type.py b/stock_location_flowable/tests/test_stock_picking_type.py index b4746e37e..b452105d1 100644 --- a/stock_location_flowable/tests/test_stock_picking_type.py +++ b/stock_location_flowable/tests/test_stock_picking_type.py @@ -1,4 +1,5 @@ -# Copyright NuoBiT Solutions - Frank Cespedes +# Copyright NuoBiT Solutions - Frank Cespedes +# Copyright 2026 NuoBiT Solutions - Eric Antones # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import logging @@ -34,3 +35,20 @@ def test_unique_flowable_operation_picking_type(self): msg_error = "Only one picking type can be flowable in a warehouse %s." msg_error = self.get_error_message_regex(msg_error) self.assertRegex(error.exception.args[0], msg_error) + + def test_non_mrp_picking_type_cannot_be_flowable(self): + """ + Test that setting flowable_operation on a non-mrp_operation picking type + raises a ValidationError. + + PRE: - An incoming picking type + ACT: - Set flowable_operation to True + POST: - ValidationError is raised + """ + # ACT & ASSERT + with self.assertRaises(ValidationError) as error: + self.picking_type_incoming_1.flowable_operation = True + + msg_error = "Only manufacturing picking types can be flowable." + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) diff --git a/stock_location_flowable/tests/test_stock_quant.py b/stock_location_flowable/tests/test_stock_quant.py new file mode 100644 index 000000000..fa97455f6 --- /dev/null +++ b/stock_location_flowable/tests/test_stock_quant.py @@ -0,0 +1,87 @@ +# Copyright 2026 NuoBiT Solutions - Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging + +from odoo.exceptions import ValidationError + +from .test_common import TestCommon + +_logger = logging.getLogger(__name__) + + +class TestStockQuant(TestCommon): + @classmethod + def setUpClass(cls): + super(TestStockQuant, cls).setUpClass() + + def test_unique_lot_constraint_at_flowable_location(self): + """ + Test that having more than one positive lot quant at a flowable location + raises a ValidationError. + + A user who does two successive inventory adjustments at the same + flowable location with different lots should be blocked on the second. + + PRE: - A flowable location with stock from a first lot (via inventory) + ACT: - Perform a second inventory adjustment adding a different lot + POST: - ValidationError is raised about duplicate lots + """ + # ARRANGE — first lot via inventory adjustment + lot_1 = self.env["stock.production.lot"].create( + { + "name": "QUANT-LOT-1", + "product_id": self.product_flowable_1.id, + } + ) + + inventory_1 = self.env["stock.inventory"].create( + { + "name": "Add first lot", + } + ) + inventory_1.action_start() + self.env["stock.inventory.line"].create( + { + "inventory_id": inventory_1.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "location_id": self.location_flowable_1.id, + "prod_lot_id": lot_1.id, + "product_qty": 100, + } + ) + inventory_1.action_validate() + + # ACT — second lot via inventory adjustment + lot_2 = self.env["stock.production.lot"].create( + { + "name": "QUANT-LOT-2", + "product_id": self.product_flowable_1.id, + } + ) + + inventory_2 = self.env["stock.inventory"].create( + { + "name": "Add second lot", + } + ) + inventory_2.action_start() + self.env["stock.inventory.line"].create( + { + "inventory_id": inventory_2.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "location_id": self.location_flowable_1.id, + "prod_lot_id": lot_2.id, + "product_qty": 50, + } + ) + + # ASSERT + with self.assertRaises(ValidationError) as error: + inventory_2.action_validate() + + msg_error = "You cannot have more than one lot in the same location." + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) diff --git a/stock_location_flowable/tests/test_stock_return_picking.py b/stock_location_flowable/tests/test_stock_return_picking.py new file mode 100644 index 000000000..d30a1bb4f --- /dev/null +++ b/stock_location_flowable/tests/test_stock_return_picking.py @@ -0,0 +1,95 @@ +# Copyright 2026 NuoBiT Solutions - Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging + +from odoo.exceptions import UserError + +from .test_common import TestCommon + +_logger = logging.getLogger(__name__) + + +class TestStockReturnPicking(TestCommon): + @classmethod + def setUpClass(cls): + super(TestStockReturnPicking, cls).setUpClass() + + def test_return_from_flowable_location_raises_error(self): + """ + Test that returning a product that was delivered to a flowable + location is blocked. + + PRE: - A completed incoming picking to a flowable location + ACT: - Attempt to create a return for that picking + POST: - UserError is raised + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot = self.env["stock.production.lot"].create( + { + "name": "TEST-RETURN-LOT", + "product_id": self.product_flowable_1.id, + } + ) + + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_incoming_1.id, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": self.location_flowable_1.id, + } + ) + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot.id, + "qty_done": 50, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": self.location_flowable_1.id, + "company_id": self.env.company.id, + } + ) + picking.button_validate() + + # Complete the mixing MO so we have a done picking + production = self.env["mrp.production"].search( + [ + ("picking_type_id", "=", self.picking_type_mrp_operation_1.id), + ("location_dest_id", "=", self.location_flowable_1.id), + ], + order="id desc", + limit=1, + ) + if production: + production.button_mark_done() + + # ACT + return_wizard = ( + self.env["stock.return.picking"] + .with_context( + active_id=picking.id, + active_model="stock.picking", + ) + .create( + { + "picking_id": picking.id, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + } + ) + ) + return_wizard._onchange_picking_id() + + with self.assertRaises(UserError) as error: + return_wizard._create_returns() + + # ASSERT + msg_error = ( + "You cannot return the following products because" + " they come from a flowable location: %s" + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) From 9ba35ed89b9b3f8b4d206577141c54f4517262a9 Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Tue, 17 Feb 2026 17:24:17 +0100 Subject: [PATCH 06/31] [FIX] stock_location_flowable: wrong product uom validation --- stock_location_flowable/models/stock_location.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/stock_location_flowable/models/stock_location.py b/stock_location_flowable/models/stock_location.py index 81714cf5e..25b2a2367 100644 --- a/stock_location_flowable/models/stock_location.py +++ b/stock_location_flowable/models/stock_location.py @@ -1,4 +1,5 @@ # Copyright NuoBiT Solutions - Frank Cespedes +# Copyright 2026 NuoBiT Solutions - Eric Antones # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) from odoo import _, api, fields, models @@ -192,7 +193,7 @@ def write(self, vals): ) ) if product.uom_id.id != vals.get( - "flowable_uom_id", rec.flowable_uom_id + "flowable_uom_id", rec.flowable_uom_id.id ): raise UserError( _( From 198b655351fd599d042196d934866b8da1147bff Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Tue, 17 Feb 2026 17:27:06 +0100 Subject: [PATCH 07/31] [IMP] stock_location_flowable: singleton error message for flowable location returns --- .../models/stock_return_picking.py | 11 ++- .../tests/test_stock_return_picking.py | 67 +++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/stock_location_flowable/models/stock_return_picking.py b/stock_location_flowable/models/stock_return_picking.py index b6cafff72..6688088fb 100644 --- a/stock_location_flowable/models/stock_return_picking.py +++ b/stock_location_flowable/models/stock_return_picking.py @@ -1,4 +1,5 @@ # Copyright NuoBiT Solutions - Frank Cespedes +# Copyright 2026 NuoBiT Solutions - Eric Antones # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) from odoo import _, models @@ -16,11 +17,15 @@ def _create_returns(self): and x.product_id in rec.product_return_moves.product_id ) if move_line: + details = ", ".join( + _("%s (%s)") % (ml.product_id.name, ml.location_dest_id.name) + for ml in move_line + ) raise UserError( _( - "You cannot return the product %s because it" - " comes from a flowable location %s." + "You cannot return the following products because" + " they come from a flowable location: %s" ) - % (move_line.product_id.name, move_line.location_dest_id.name) + % details ) return res diff --git a/stock_location_flowable/tests/test_stock_return_picking.py b/stock_location_flowable/tests/test_stock_return_picking.py index d30a1bb4f..036c3b4c4 100644 --- a/stock_location_flowable/tests/test_stock_return_picking.py +++ b/stock_location_flowable/tests/test_stock_return_picking.py @@ -93,3 +93,70 @@ def test_return_from_flowable_location_raises_error(self): ) msg_error = self.get_error_message_regex(msg_error) self.assertRegex(error.exception.args[0], msg_error) + + def test_return_from_non_flowable_location_succeeds(self): + """ + Test that returning a product that was delivered to a non-flowable + location is allowed. + + PRE: - A completed incoming picking to a non-flowable location + ACT: - Create a return for that picking + POST: - Return picking is created successfully + """ + # ARRANGE + lot = self.env["stock.production.lot"].create( + { + "name": "TEST-RETURN-NOFLO", + "product_id": self.product_flowable_1.id, + } + ) + + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_incoming_1.id, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": self.location_1.id, + } + ) + move = self.env["stock.move"].create( + { + "name": self.product_flowable_1.name, + "product_id": self.product_flowable_1.id, + "product_uom_qty": 50, + "product_uom": self.product_flowable_1.uom_id.id, + "picking_id": picking.id, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": self.location_1.id, + } + ) + picking.action_confirm() + picking.action_assign() + move.move_line_ids.write( + { + "qty_done": 50, + "lot_id": lot.id, + } + ) + picking.button_validate() + + # ACT + return_wizard = ( + self.env["stock.return.picking"] + .with_context( + active_id=picking.id, + active_model="stock.picking", + ) + .create( + { + "picking_id": picking.id, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + } + ) + ) + return_wizard._onchange_picking_id() + new_picking_id, pick_type_id = return_wizard._create_returns() + + # ASSERT + self.assertTrue(new_picking_id) + return_picking = self.env["stock.picking"].browse(new_picking_id) + self.assertEqual(return_picking.state, "assigned") From 15a6afdad35b8c32d34e8d84b2869f3103ee0938 Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Tue, 17 Feb 2026 17:30:05 +0100 Subject: [PATCH 08/31] [REF] stock_location_flowable: refactor _create_returns method to streamline return process --- stock_location_flowable/models/stock_return_picking.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/stock_location_flowable/models/stock_return_picking.py b/stock_location_flowable/models/stock_return_picking.py index 6688088fb..435b09d2a 100644 --- a/stock_location_flowable/models/stock_return_picking.py +++ b/stock_location_flowable/models/stock_return_picking.py @@ -10,7 +10,6 @@ class ReturnPicking(models.TransientModel): _inherit = "stock.return.picking" def _create_returns(self): - res = super()._create_returns() for rec in self: move_line = rec.picking_id.move_line_ids_without_package.filtered( lambda x: x.location_dest_id.flowable_storage @@ -28,4 +27,4 @@ def _create_returns(self): ) % details ) - return res + return super()._create_returns() From 4cf70436e903913374ebc350037dd46c7b27bba2 Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Tue, 17 Feb 2026 17:34:18 +0100 Subject: [PATCH 09/31] [REF] stock_location_flowable: refactor _create_returns method and remove the multi treatment --- .../models/stock_return_picking.py | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/stock_location_flowable/models/stock_return_picking.py b/stock_location_flowable/models/stock_return_picking.py index 435b09d2a..ca5d655fe 100644 --- a/stock_location_flowable/models/stock_return_picking.py +++ b/stock_location_flowable/models/stock_return_picking.py @@ -10,21 +10,21 @@ class ReturnPicking(models.TransientModel): _inherit = "stock.return.picking" def _create_returns(self): - for rec in self: - move_line = rec.picking_id.move_line_ids_without_package.filtered( - lambda x: x.location_dest_id.flowable_storage - and x.product_id in rec.product_return_moves.product_id + self.ensure_one() + move_line = self.picking_id.move_line_ids_without_package.filtered( + lambda x: x.location_dest_id.flowable_storage + and x.product_id in self.product_return_moves.product_id + ) + if move_line: + details = ", ".join( + _("%s (%s)") % (ml.product_id.name, ml.location_dest_id.name) + for ml in move_line ) - if move_line: - details = ", ".join( - _("%s (%s)") % (ml.product_id.name, ml.location_dest_id.name) - for ml in move_line - ) - raise UserError( - _( - "You cannot return the following products because" - " they come from a flowable location: %s" - ) - % details + raise UserError( + _( + "You cannot return the following products because" + " they come from a flowable location: %s" ) + % details + ) return super()._create_returns() From c33bb4765caf58d2acb18986ae6cf0c1ee188761 Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Tue, 17 Feb 2026 18:15:42 +0100 Subject: [PATCH 10/31] [FIX] stock_location_flowable: flowable locations won't never be full --- stock_location_flowable/models/stock_location.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stock_location_flowable/models/stock_location.py b/stock_location_flowable/models/stock_location.py index 25b2a2367..f1c2471be 100644 --- a/stock_location_flowable/models/stock_location.py +++ b/stock_location_flowable/models/stock_location.py @@ -95,7 +95,7 @@ def _check_flowable_uom_id(self): def _check_flowable_capacity_occupied(self): for rec in self: if rec.usage != "view" and rec.flowable_storage: - if rec.flowable_capacity_occupied >= rec.flowable_capacity: + if rec.flowable_capacity_occupied > rec.flowable_capacity: raise ValidationError(_("Location capacity is full")) @api.constrains("flowable_storage") From e0b18f9b2277ed2aadc437a35dcdeeaece894f51 Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Tue, 17 Feb 2026 18:28:45 +0100 Subject: [PATCH 11/31] [REF] stock_location_flowable: not using a shallow lot variable, use a dedicated producing_lot variable --- stock_location_flowable/models/stock_picking.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/stock_location_flowable/models/stock_picking.py b/stock_location_flowable/models/stock_picking.py index 9029b2757..bedf56671 100644 --- a/stock_location_flowable/models/stock_picking.py +++ b/stock_location_flowable/models/stock_picking.py @@ -1,4 +1,5 @@ # Copyright NuoBiT Solutions - Frank Cespedes +# Copyright 2026 NuoBiT Solutions - Eric Antones # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) from odoo import _, fields, models @@ -183,9 +184,11 @@ def _action_done(self): production._onchange_location_dest() production.action_confirm() if location_dest.flowable_create_lots: - lot = rec.env["stock.production.lot"].create( + producing_lot = rec.env["stock.production.lot"].create( rec._prepare_lot_values(product, location_dest, qty_done) ) + else: + producing_lot = lot vals = [] for move_line in component_quant: vals.append( @@ -198,7 +201,7 @@ def _action_done(self): ) ) production.move_raw_ids.move_line_ids = vals - production.lot_producing_id = lot + production.lot_producing_id = producing_lot production.action_assign() production.qty_producing = quantity_to_prod return res From 753a6a2f1cb352a9bf874c0c876e29fb299931ef Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Tue, 17 Feb 2026 18:30:08 +0100 Subject: [PATCH 12/31] [IMP] stock_location_flowable: adapt tests to flowable locations will never be full --- stock_location_flowable/tests/test_stock_location.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/stock_location_flowable/tests/test_stock_location.py b/stock_location_flowable/tests/test_stock_location.py index 5fd4e3a72..556ee3cbf 100644 --- a/stock_location_flowable/tests/test_stock_location.py +++ b/stock_location_flowable/tests/test_stock_location.py @@ -591,11 +591,11 @@ def test_cannot_remove_stored_product_from_allowed(self): def test_capacity_full_raises_error(self): """ - Test that receiving stock that fills the flowable location to its + Test that receiving stock that exceeds the flowable location capacity triggers the capacity constraint. PRE: - A flowable location with capacity 100 - ACT: - Receive 100 litres (exactly the capacity) + ACT: - Receive 101 litres (exceeds the capacity) POST: - ValidationError is raised about location capacity being full """ # ARRANGE @@ -620,7 +620,7 @@ def test_capacity_full_raises_error(self): "product_id": product.id, "product_uom_id": product.uom_id.id, "lot_id": lot.id, - "qty_done": 100, + "qty_done": 101, "location_id": self.env.ref("stock.stock_location_suppliers").id, "location_dest_id": self.location_flowable_1.id, "company_id": self.env.company.id, From 59503c973750014826aaf0975abf8950ee82fede Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Tue, 17 Feb 2026 23:13:13 +0100 Subject: [PATCH 13/31] [REF] stock_location_flowable: avoid redundant mrp_operation_type search per product Move mrp_operation_type search and validation out of the per-product loop and into a single check per picking, running only when there are flowable destination lines. Skip non-flowable pickings early with continue. Update error messages to reference warehouse instead of location: - "Not found flowable manufacturing picking type in warehouse %s" - "More than one flowable manufacturing picking type in warehouse %s" - "Not found sequence in flowable manufacturing picking type %s" Adapt tests to match the new validation order and messages: - Enable flowable_operation in tests that expect product-level errors (one product, not allowed, different UoM) since the mrp_operation_type check now runs before those validations - Update expected error message strings in test_not_found_manufacturing_picking_type_incoming_picking and test_mrp_operation_type_without_sequence_raises_error --- .../models/stock_picking.py | 101 ++++++++++-------- .../tests/test_stock_picking.py | 69 ++++++++++-- 2 files changed, 119 insertions(+), 51 deletions(-) diff --git a/stock_location_flowable/models/stock_picking.py b/stock_location_flowable/models/stock_picking.py index bedf56671..3b2664a22 100644 --- a/stock_location_flowable/models/stock_picking.py +++ b/stock_location_flowable/models/stock_picking.py @@ -101,21 +101,65 @@ def button_validate(self): def _action_done(self): res = super()._action_done() for rec in self: + flowable_lines = rec.move_line_ids_without_package.filtered( + lambda x: x.location_dest_id.flowable_storage + ) + if not flowable_lines: + continue + + # checks before creating manufacturing orders + mrp_operation_type = rec.env["stock.picking.type"].search( + [ + ("warehouse_id", "=", rec.picking_type_id.warehouse_id.id), + ("code", "=", "mrp_operation"), + ("flowable_operation", "=", True), + ] + ) + if not mrp_operation_type: + raise UserError( + _( + "Not found flowable manufacturing picking type" + " in warehouse %s" + ) + % rec.picking_type_id.warehouse_id.name + ) + elif len(mrp_operation_type) > 1: + raise UserError( + _( + "More than one flowable manufacturing picking type" + " in warehouse %s" + ) + % rec.picking_type_id.warehouse_id.name + ) + else: + if not mrp_operation_type.sequence_id: + raise UserError( + _( + "Not found sequence in flowable manufacturing" + " picking type %s" + ) + % mrp_operation_type.display_name + ) + + # group move lines by product, destination location and lot to check if + # there are multiple products for the same location and to sum the + # quantity to produce lines = {} - for line in rec.move_line_ids_without_package: - if line.location_dest_id.flowable_storage: - key = (line.product_id, line.location_dest_id, line.lot_id) - lines[key] = lines.get(key, 0) + line.qty_done - if any(k[1] == line.location_dest_id and k != key for k in lines): - raise UserError( - _( - "You can only receive one product at location %s" - " because a manufacturing order must be generated" - " and the location will be blocked. Create a " - "partial delivery for this product %s." - ) - % (line.location_dest_id.name, line.product_id.name) + for line in flowable_lines: + key = (line.product_id, line.location_dest_id, line.lot_id) + lines[key] = lines.get(key, 0) + line.qty_done + if any(k[1] == line.location_dest_id and k != key for k in lines): + raise UserError( + _( + "You can only receive one product at location %s" + " because a manufacturing order must be generated" + " and the location will be blocked. Create a " + "partial delivery for this product %s." ) + % (line.location_dest_id.name, line.product_id.name) + ) + + # create manufacturing orders for (product, location_dest, lot), qty_done in lines.items(): if product not in location_dest.flowable_allowed_product_ids: raise UserError( @@ -134,37 +178,6 @@ def _action_done(self): raise UserError( _("Product %s must be tracked by lot") % product.name ) - mrp_operation_type = rec.env["stock.picking.type"].search( - [ - ("warehouse_id", "=", rec.picking_type_id.warehouse_id.id), - ("code", "=", "mrp_operation"), - ("flowable_operation", "=", True), - ] - ) - if not mrp_operation_type: - raise UserError( - _( - "Not found manufacturing picking type for flowable" - " location %s to do flowable mixing in %s" - ) - % (location_dest.name, rec.picking_type_id.warehouse_id.name) - ) - if len(mrp_operation_type) > 1: - raise UserError( - _( - "More than one manufacturing code in picking type for" - " flowable location %s" - ) - % location_dest.name - ) - if not mrp_operation_type.sequence_id: - raise UserError( - _( - "Not found sequence in manufacturing picking type %s" - " for flowable location %s" - ) - % (mrp_operation_type.display_name, location_dest.name) - ) component_quant = rec.env["stock.quant"].search( [ ("product_id", "=", product.id), diff --git a/stock_location_flowable/tests/test_stock_picking.py b/stock_location_flowable/tests/test_stock_picking.py index 83cc59a30..eedd03514 100644 --- a/stock_location_flowable/tests/test_stock_picking.py +++ b/stock_location_flowable/tests/test_stock_picking.py @@ -59,6 +59,8 @@ def test_receiving_one_product_in_flowable_location_incoming_picking(self): at a flowable location """ # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + moves1 = self.env["stock.move"].create( { "name": self.product_flowable_1.name, @@ -139,6 +141,8 @@ def test_receiving_one_product_in_flowable_location_incoming_picking(self): def test_only_allowed_product_in_incoming_picking(self): # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + product_zanahoria = self.env["product.product"].create( { "name": "Zanahoria", @@ -193,6 +197,8 @@ def test_only_allowed_product_in_incoming_picking(self): def test_different_uom_allowed_product_in_incoming_picking(self): # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + product_zanahoria = self.env["product.product"].create( { "name": "Zanahoria", @@ -301,10 +307,62 @@ def test_not_found_manufacturing_picking_type_incoming_picking(self): self.incoming_picking.button_validate() # ASSERT - msg_error = ( - "Not found manufacturing picking type for flowable" - " location %s to do flowable mixing in %s" + msg_error = "Not found flowable manufacturing picking type in warehouse %s" + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_more_than_one_manufacturing_picking_type_incoming_picking(self): + """ + Test that having more than one flowable manufacturing picking type + in the same warehouse raises an error during picking validation. + + PRE: - Two flowable mrp_operation picking types in the same warehouse + (second one created bypassing the ORM constraint via SQL) + ACT: - Validate a picking to a flowable location + POST: - UserError is raised about duplicate picking types + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + picking_type_mrp_operation_2 = self.env["stock.picking.type"].create( + { + "name": "Production2", + "sequence_code": "SEQ-MRP2", + "code": "mrp_operation", + "warehouse_id": self.picking_type_mrp_operation_1.warehouse_id.id, + } ) + # Bypass the ORM constraint to simulate data inconsistency + self.env.cr.execute( + "UPDATE stock_picking_type SET flowable_operation = TRUE WHERE id = %s", + (picking_type_mrp_operation_2.id,), + ) + picking_type_mrp_operation_2.invalidate_cache() + + lot_1 = self.env["stock.production.lot"].create( + { + "name": "TEST-DUP-LOT", + "product_id": self.product_flowable_1.id, + } + ) + + self.env["stock.move.line"].create( + { + "picking_id": self.incoming_picking.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot_1.id, + "qty_done": 10, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": self.incoming_picking.location_dest_id.id, + "company_id": self.env.company.id, + } + ) + + # ACT & ASSERT + with self.assertRaises(UserError) as error: + self.incoming_picking.button_validate() + + msg_error = "More than one flowable manufacturing picking type in warehouse %s" msg_error = self.get_error_message_regex(msg_error) self.assertRegex(error.exception.args[0], msg_error) @@ -473,10 +531,7 @@ def test_mrp_operation_type_without_sequence_raises_error(self): with self.assertRaises(UserError) as error: self.incoming_picking.button_validate() - msg_error = ( - "Not found sequence in manufacturing picking type %s" - " for flowable location %s" - ) + msg_error = "Not found sequence in flowable manufacturing picking type %s" msg_error = self.get_error_message_regex(msg_error) self.assertRegex(error.exception.args[0], msg_error) From d746498f06afe953ebd2d38ea5d9bd4ae82c9256 Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Fri, 27 Feb 2026 19:51:24 +0100 Subject: [PATCH 14/31] [FIX] stock_location_flowable: non-MO moves bypass blocked location check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blocked-location constraint in stock_move_line.py used the move's state to decide which production field to check (production_id vs raw_material_production_id). Moves not linked to any MO (e.g. a second PO reception or an internal transfer) had production=False, and the guard `if production and ...` was skipped entirely — allowing writes to a blocked location. Change the production lookup to `raw_material_production_id or production_id` (always finds the linked MO if any) and invert the guard to check `if location.flowable_production_id and ... \!= production` so the constraint fires on any move to a blocked location, regardless of origin. --- .../models/stock_move_line.py | 13 ++- .../tests/test_stock_move_line.py | 110 ++++++++++++++++++ 2 files changed, 118 insertions(+), 5 deletions(-) diff --git a/stock_location_flowable/models/stock_move_line.py b/stock_location_flowable/models/stock_move_line.py index 3ea09ca3b..2728240b2 100644 --- a/stock_location_flowable/models/stock_move_line.py +++ b/stock_location_flowable/models/stock_move_line.py @@ -28,11 +28,14 @@ def _check_flowable_location_blocked(self): ): locations_to_check |= rec.location_id for location in locations_to_check: - if rec.state == "done": - production = rec.move_id.production_id - else: - production = rec.move_id.raw_material_production_id - if production and location.flowable_production_id != production: + production = ( + rec.move_id.raw_material_production_id + or rec.move_id.production_id + ) + if ( + location.flowable_production_id + and location.flowable_production_id != production + ): raise ValidationError( _( "The location %s is blocked. Probably you need to" diff --git a/stock_location_flowable/tests/test_stock_move_line.py b/stock_location_flowable/tests/test_stock_move_line.py index de0fc5e76..91a3ace04 100644 --- a/stock_location_flowable/tests/test_stock_move_line.py +++ b/stock_location_flowable/tests/test_stock_move_line.py @@ -3,6 +3,8 @@ import logging +from odoo.exceptions import ValidationError + from .test_common import TestCommon _logger = logging.getLogger(__name__) @@ -39,3 +41,111 @@ def test_blocked_location_rejects_unrelated_production_done(self): ) msg_error = self.get_error_message_regex(msg_error) self.assertRegex(error.exception.args[0], msg_error) + + def test_blocked_location_rejects_incoming_reception(self): + """ + Test that a second PO reception to a blocked flowable location + is rejected even though the reception is not part of any MO. + + PRE: - A flowable location blocked by a production (from a first reception) + - A second incoming picking prepared before the location was blocked + ACT: - Try to validate the second incoming picking + POST: - A ValidationError is raised about the location being blocked + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_2 = self.env["stock.production.lot"].create( + { + "name": "Lot2", + "product_id": self.product_flowable_1.id, + } + ) + second_picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_incoming_1.id, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": self.location_flowable_1.id, + } + ) + self.env["stock.move.line"].create( + { + "picking_id": second_picking.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot_2.id, + "qty_done": 10, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": self.location_flowable_1.id, + "company_id": self.env.company.id, + } + ) + + # Block the location by validating the first reception + self.incoming_picking.button_validate() + self.assertTrue(self.location_flowable_1.flowable_blocked) + + # ACT & ASSERT + with self.assertRaises(ValidationError) as error: + second_picking.button_validate() + + msg_error = ( + "The location %s is blocked. Probably you need to review" + " the pending manufacturing orders related to this location" + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_blocked_location_rejects_internal_transfer(self): + """ + Test that an internal transfer from a blocked flowable location + is rejected. + + PRE: - A flowable location blocked by a production + - An internal transfer prepared before the location was blocked + ACT: - Try to validate the internal transfer + POST: - An error is raised about the location being blocked + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_2 = self.env["stock.production.lot"].create( + { + "name": "LotInternal", + "product_id": self.product_flowable_1.id, + } + ) + transfer_picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_internal_1.id, + "location_id": self.location_flowable_1.id, + "location_dest_id": self.location_1.id, + } + ) + self.env["stock.move.line"].create( + { + "picking_id": transfer_picking.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot_2.id, + "qty_done": 5, + "location_id": self.location_flowable_1.id, + "location_dest_id": self.location_1.id, + "company_id": self.env.company.id, + } + ) + + # Block the location by validating the first reception + self.incoming_picking.button_validate() + self.assertTrue(self.location_flowable_1.flowable_blocked) + + # ACT & ASSERT + with self.assertRaises(Exception) as error: + transfer_picking.button_validate() + + msg_error = ( + "The location %s is blocked. Probably you need to review" + " the pending manufacturing orders related to this location" + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) From ad7401de9e583a725a7f8c677f0f86c88d68a724 Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Fri, 27 Feb 2026 20:06:36 +0100 Subject: [PATCH 15/31] [FIX] stock_location_flowable: prevent premature blocking and reservation conflicts Before this fix, flowable locations were blocked as soon as raw materials entered confirmed or partially_available state, causing false rejections of incoming receipts. Now blocking only occurs when all raw materials reach assigned state. Additionally, _trigger_assign is bypassed during picking completion to prevent automatic reservation of unrelated moves at the flowable location, and a post-reservation check (action_assign override) ensures the mixing order can be fully reserved before proceeding. --- stock_location_flowable/i18n/ca.po | 58 +++ stock_location_flowable/i18n/es.po | 42 +++ .../models/mrp_production.py | 75 +++- stock_location_flowable/models/stock_move.py | 5 + .../models/stock_picking.py | 5 +- .../tests/test_mrp_production.py | 352 ++++++++++++++++++ 6 files changed, 535 insertions(+), 2 deletions(-) create mode 100644 stock_location_flowable/i18n/ca.po diff --git a/stock_location_flowable/i18n/ca.po b/stock_location_flowable/i18n/ca.po new file mode 100644 index 000000000..53a071cff --- /dev/null +++ b/stock_location_flowable/i18n/ca.po @@ -0,0 +1,58 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * stock_location_flowable +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-11-27 11:13+0000\n" +"PO-Revision-Date: 2023-11-27 11:13+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/mrp_production.py:0 +#, python-format +msgid "" +"Cannot fully reserve the mixing order at flowable location '%s'. Raw " +"materials are in state '%s'." +msgstr "" +"No es pot reservar completament l'ordre de barreja a la ubicació fluida " +"'%s'. Les matèries primeres estan en estat '%s'." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/mrp_production.py:0 +#, python-format +msgid "" +"Cannot merge at flowable location '%s' because there are reserved " +"quantities. After the merge, the current lot(s) will have 0 stock and these " +"reservations will become invalid.\n" +"\n" +"The following operations must be unreserved or completed first:\n" +"\n" +"%s" +msgstr "" +"No es pot barrejar a la ubicació fluida '%s' perquè hi ha quantitats " +"reservades. Després de la barreja, els lots actuals tindran 0 estoc i " +"aquestes reserves seran invàlides.\n" +"\n" +"Les següents operacions s'han d'alliberar o completar primer:\n" +"\n" +"%s" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/mrp_production.py:0 +#, python-format +msgid "Unknown origin (move %s)" +msgstr "Origen desconegut (moviment %s)" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/mrp_production.py:0 +#, python-format +msgid "no lot" +msgstr "sense lot" diff --git a/stock_location_flowable/i18n/es.po b/stock_location_flowable/i18n/es.po index 398a75335..e94f2df02 100644 --- a/stock_location_flowable/i18n/es.po +++ b/stock_location_flowable/i18n/es.po @@ -34,6 +34,36 @@ msgstr "Productos Permitidos" msgid "Blocked" msgstr "Bloqueado" +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/mrp_production.py:0 +#, python-format +msgid "" +"Cannot fully reserve the mixing order at flowable location '%s'. Raw " +"materials are in state '%s'." +msgstr "" +"No se puede reservar completamente la orden de mezcla en la ubicación fluida " +"'%s'. Las materias primas están en estado '%s'." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/mrp_production.py:0 +#, python-format +msgid "" +"Cannot merge at flowable location '%s' because there are reserved " +"quantities. After the merge, the current lot(s) will have 0 stock and these " +"reservations will become invalid.\n" +"\n" +"The following operations must be unreserved or completed first:\n" +"\n" +"%s" +msgstr "" +"No se puede mezclar en la ubicación fluida '%s' porque hay cantidades " +"reservadas. Después de la mezcla, los lotes actuales tendrán 0 stock y " +"estas reservas serán inválidas.\n" +"\n" +"Las siguientes operaciones deben ser liberadas o completadas primero:\n" +"\n" +"%s" + #. module: stock_location_flowable #: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_capacity #: model_terms:ir.ui.view,arch_db:stock_location_flowable.stock_location_view_form @@ -292,6 +322,12 @@ msgstr "Las transferencias le permiten mover productos de un lugar a otro." msgid "Unit of Measure" msgstr "Unidad de Medida" +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/mrp_production.py:0 +#, python-format +msgid "Unknown origin (move %s)" +msgstr "Origen desconocido (movimiento %s)" + #. module: stock_location_flowable #: code:addons/stock_location_flowable/models/stock_picking.py:0 #, python-format @@ -370,3 +406,9 @@ msgstr "Debes seleccionar una unidad de medida." #, python-format msgid "You must select products" msgstr "Debes seleccionar productos." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/mrp_production.py:0 +#, python-format +msgid "no lot" +msgstr "sin lote" diff --git a/stock_location_flowable/models/mrp_production.py b/stock_location_flowable/models/mrp_production.py index da892b071..874d76616 100644 --- a/stock_location_flowable/models/mrp_production.py +++ b/stock_location_flowable/models/mrp_production.py @@ -3,7 +3,7 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) from odoo import _, api, fields, models -from odoo.exceptions import ValidationError +from odoo.exceptions import UserError, ValidationError class MrpProduction(models.Model): @@ -56,3 +56,76 @@ def write(self, vals): ) ) return super().write(vals) + + def action_assign(self): + res = super().action_assign() + for rec in self: + if rec.picking_type_id.flowable_operation and rec.state not in ( + "to_close", + "done", + "cancel", + ): + rec._check_flowable_reservation() + return res + + def _check_flowable_reservation(self): + self.ensure_one() + if all(m.state == "assigned" for m in self.move_raw_ids): + return + location = self.location_src_id + reserved_move_lines = self.env["stock.move.line"].search( + [ + ("location_id", "=", location.id), + ("product_uom_qty", ">", 0), + ("state", "not in", ("done", "cancel", "draft")), + ("move_id.raw_material_production_id", "!=", self.id), + ] + ) + if reserved_move_lines: + details = [] + for ml in reserved_move_lines: + move = ml.move_id + if move.picking_id: + origin = "%s (%s)" % ( + move.picking_id.name, + move.picking_id.picking_type_id.name, + ) + elif move.raw_material_production_id: + origin = "%s (%s)" % ( + move.raw_material_production_id.name, + move.raw_material_production_id.picking_type_id.name, + ) + else: + origin = _("Unknown origin (move %s)", move.id) + details.append( + " - %s: %s %s (lot %s) - %s" + % ( + ml.product_id.display_name, + ml.product_uom_qty, + ml.product_uom_id.name, + ml.lot_id.name or _("no lot"), + origin, + ) + ) + raise UserError( + _( + "Cannot merge at flowable location '%s'" + " because there are reserved quantities." + " After the merge, the current lot(s) will" + " have 0 stock and these reservations will" + " become invalid.\n\n" + "The following operations must be unreserved" + " or completed first:\n\n%s", + location.name, + "\n".join(details), + ) + ) + raise UserError( + _( + "Cannot fully reserve the mixing order at" + " flowable location '%s'. Raw materials are" + " in state '%s'.", + location.name, + ", ".join(self.move_raw_ids.mapped("state")), + ) + ) diff --git a/stock_location_flowable/models/stock_move.py b/stock_location_flowable/models/stock_move.py index 8e184a8f4..689224c29 100644 --- a/stock_location_flowable/models/stock_move.py +++ b/stock_location_flowable/models/stock_move.py @@ -8,6 +8,11 @@ class StockMove(models.Model): _inherit = "stock.move" + def _trigger_assign(self): + if self.env.context.get("flowable_skip_trigger_assign"): + return + return super()._trigger_assign() + def write(self, vals): for rec in self: production = rec.raw_material_production_id diff --git a/stock_location_flowable/models/stock_picking.py b/stock_location_flowable/models/stock_picking.py index 3b2664a22..fe0e3e196 100644 --- a/stock_location_flowable/models/stock_picking.py +++ b/stock_location_flowable/models/stock_picking.py @@ -99,7 +99,10 @@ def button_validate(self): return super().button_validate() def _action_done(self): - res = super()._action_done() + res = super( + StockPicking, + self.with_context(flowable_skip_trigger_assign=True), + )._action_done() for rec in self: flowable_lines = rec.move_line_ids_without_package.filtered( lambda x: x.location_dest_id.flowable_storage diff --git a/stock_location_flowable/tests/test_mrp_production.py b/stock_location_flowable/tests/test_mrp_production.py index d628f239a..2282d99af 100644 --- a/stock_location_flowable/tests/test_mrp_production.py +++ b/stock_location_flowable/tests/test_mrp_production.py @@ -5,6 +5,7 @@ import logging from odoo.exceptions import UserError, ValidationError +from odoo.tests import common from odoo.tools import float_compare, float_round from .test_common import TestCommon @@ -829,3 +830,354 @@ def test_production_without_bom_allowed_for_flowable(self): # ASSERT self.assertTrue(production) + + +class TestFlowableBlockingWithReservations(common.SavepointCase): + @classmethod + def setUpClass(cls): + super(TestFlowableBlockingWithReservations, cls).setUpClass() + + cls.picking_type_incoming = cls.env["stock.picking.type"].create( + { + "name": "TestReceipt", + "sequence_code": "SEQ-TEST-IN", + "code": "incoming", + "default_location_dest_id": cls.env.ref( + "stock.stock_location_locations_partner" + ).id, + } + ) + + cls.picking_type_outgoing = cls.env["stock.picking.type"].create( + { + "name": "TestDelivery", + "sequence_code": "SEQ-TEST-OUT", + "code": "outgoing", + "default_location_src_id": cls.env.ref( + "stock.stock_location_locations_partner" + ).id, + } + ) + + cls.picking_type_mrp = cls.env["stock.picking.type"].create( + { + "name": "TestProduction", + "sequence_code": "SEQ-TEST-MRP", + "code": "mrp_operation", + "flowable_operation": True, + } + ) + + cls.product = cls.env["product.product"].create( + { + "name": "TestOxygen", + "type": "product", + "uom_id": cls.env.ref("uom.product_uom_litre").id, + "uom_po_id": cls.env.ref("uom.product_uom_litre").id, + "tracking": "lot", + } + ) + + cls.location_fl1 = cls.env["stock.location"].create( + { + "name": "FL1", + "usage": "internal", + "location_id": cls.env.ref("stock.stock_location_locations_partner").id, + "flowable_storage": True, + "flowable_capacity": 15000, + "flowable_uom_id": cls.env.ref("uom.product_uom_litre").id, + "flowable_allowed_product_ids": [(4, cls.product.id)], + } + ) + + cls.supplier_location = cls.env.ref("stock.stock_location_suppliers") + cls.customer_location = cls.env.ref("stock.stock_location_customers") + + def _receive_stock(self, location, product, lot, qty): + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_incoming.id, + "location_id": self.supplier_location.id, + "location_dest_id": location.id, + } + ) + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": product.id, + "product_uom_id": product.uom_id.id, + "lot_id": lot.id, + "qty_done": qty, + "location_id": self.supplier_location.id, + "location_dest_id": location.id, + "company_id": self.env.company.id, + } + ) + picking.button_validate() + return picking + + def _find_flowable_production(self, location): + return self.env["mrp.production"].search( + [ + ("picking_type_id", "=", self.picking_type_mrp.id), + ("location_dest_id", "=", location.id), + ], + order="id desc", + limit=1, + ) + + def _get_location_quants(self, location, product): + return self.env["stock.quant"].search( + [ + ("location_id", "=", location.id), + ("product_id", "=", product.id), + ] + ) + + def _get_positive_quantity(self, location, product): + quants = self._get_location_quants(location, product) + return sum(quants.filtered(lambda q: q.quantity > 0).mapped("quantity")) + + def _seed_flowable_location(self, location, product, lot, qty): + self._receive_stock(location, product, lot, qty) + production = self._find_flowable_production(location) + if production: + production.button_mark_done() + + def _create_sale_picking( + self, + location, + product, + name, + qty, + reserve=True, + unreserve=False, + ): + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_outgoing.id, + "location_id": location.id, + "location_dest_id": self.customer_location.id, + } + ) + self.env["stock.move"].create( + { + "name": name, + "picking_id": picking.id, + "product_id": product.id, + "product_uom": product.uom_id.id, + "product_uom_qty": qty, + "location_id": location.id, + "location_dest_id": self.customer_location.id, + } + ) + picking.action_confirm() + if reserve: + picking.action_assign() + if unreserve: + picking.do_unreserve() + return picking + + def test_flowable_blocking_with_pending_reservations_and_reception(self): + """ + Test that receiving stock at a flowable location is rejected when + there are reserved quantities that would become invalid after the + merge (the current lot goes to 0 stock). + + Sales 1 and 3 are explicitly reserved (assigned). Sale 2 is only + confirmed (not reserved). The mixing MO cannot fully reserve + because Sales 1 and 3 hold reservations on the stock. + + PRE: - Flowable location FL1 (capacity 15000 L), initially empty + - Receive 7000 L of lot X1 via reception + MO (seed) + - Sale 1: 100 L of X1 confirmed + reserved (assigned) + - Sale 2: 200 L of X1 confirmed only (not reserved) + - Inventory adjustment: +1000 L on lot X1 + - Sale 3: 600 L of X1 confirmed + reserved (assigned) + ACT: - Receive 5000 L of lot P1 at FL1 + POST: - UserError is raised mentioning Sales 1 and 3 + (the only ones with active reservations) + """ + # ARRANGE + lot_x1 = self.env["stock.production.lot"].create( + { + "name": "X1", + "product_id": self.product.id, + } + ) + self._seed_flowable_location(self.location_fl1, self.product, lot_x1, 7000) + self.assertEqual( + self._get_positive_quantity(self.location_fl1, self.product), 7000 + ) + self.assertFalse(self.location_fl1.flowable_blocked) + + # Sale 1: 100 L of X1, confirmed + reserved (assigned) + sale_picking_1 = self._create_sale_picking( + self.location_fl1, self.product, "Sale 1 - X1 100L", 100 + ) + self.assertEqual(sale_picking_1.state, "assigned") + + # Sale 2: 200 L of X1, confirmed only (not reserved) + sale_picking_2 = self._create_sale_picking( + self.location_fl1, + self.product, + "Sale 2 - X1 200L", + 200, + reserve=False, + ) + self.assertEqual(sale_picking_2.state, "confirmed") + + # Inventory adjustment: +1000 L on lot X1 + inventory = self.env["stock.inventory"].create( + { + "name": "Adjust +1000L on X1", + "location_ids": [(4, self.location_fl1.id)], + "product_ids": [(4, self.product.id)], + } + ) + inventory.action_start() + inv_line = inventory.line_ids.filtered( + lambda l: l.location_id == self.location_fl1 + ) + inv_line[0].product_qty = inv_line[0].product_qty + 1000 + inventory.action_validate() + self.assertEqual( + self._get_positive_quantity(self.location_fl1, self.product), 8000 + ) + + # Sale 3: 600 L of X1, confirmed + reserved (assigned) + sale_picking_3 = self._create_sale_picking( + self.location_fl1, self.product, "Sale 3 - X1 600L", 600 + ) + self.assertEqual(sale_picking_3.state, "assigned") + self.assertFalse(self.location_fl1.flowable_blocked) + + # ACT + lot_p1 = self.env["stock.production.lot"].create( + { + "name": "P1", + "product_id": self.product.id, + } + ) + with self.assertRaises(UserError) as error: + self._receive_stock(self.location_fl1, self.product, lot_p1, 5000) + + # ASSERT + # Only Sales 1 and 3 appear in the error (the ones with active + # reservations). Sale 2 is only confirmed, not reserved. + expected_msg = ( + "Cannot merge at flowable location 'FL1'" + " because there are reserved quantities." + " After the merge, the current lot(s) will" + " have 0 stock and these reservations will" + " become invalid.\n\n" + "The following operations must be unreserved" + " or completed first:\n\n" + " - TestOxygen: 100.0 L (lot X1)" + " - %s (TestDelivery)\n" + " - TestOxygen: 600.0 L (lot X1)" + " - %s (TestDelivery)" + ) % (sale_picking_1.name, sale_picking_3.name) + self.assertEqual(str(error.exception), expected_msg) + + def test_flowable_merge_succeeds_with_unreserved_operations(self): + """ + Test that receiving stock at a flowable location succeeds when + all sales have been unreserved before the reception. + + _trigger_assign is bypassed for flowable receptions, so the + unreserved sales stay confirmed. The mixing MO fully reserves + all stock and succeeds. + + PRE: - Flowable location FL1 (capacity 15000 L), initially empty + - Receive 7000 L of lot X1 via reception + MO (seed) + - Sale 1: 100 L of X1 reserved then unreserved + - Sale 2: 200 L of X1 reserved then unreserved + - Inventory adjustment: +1000 L on lot X1 + - Sale 3: 600 L of X1 reserved then unreserved + ACT: - Receive 5000 L of lot P1 at FL1 + POST: - No error is raised + - A mixing MO is created and FL1 is blocked + """ + # ARRANGE + lot_x1 = self.env["stock.production.lot"].create( + { + "name": "X1", + "product_id": self.product.id, + } + ) + self._seed_flowable_location(self.location_fl1, self.product, lot_x1, 7000) + self.assertEqual( + self._get_positive_quantity(self.location_fl1, self.product), 7000 + ) + self.assertFalse(self.location_fl1.flowable_blocked) + + # Sale 1: 100 L of X1, reserved then unreserved + sale_picking_1 = self._create_sale_picking( + self.location_fl1, + self.product, + "Sale 1 - X1 100L", + 100, + unreserve=True, + ) + self.assertEqual(sale_picking_1.state, "confirmed") + + # Sale 2: 200 L of X1, reserved then unreserved + sale_picking_2 = self._create_sale_picking( + self.location_fl1, + self.product, + "Sale 2 - X1 200L", + 200, + unreserve=True, + ) + self.assertEqual(sale_picking_2.state, "confirmed") + + # Inventory adjustment: +1000 L on lot X1 + inventory = self.env["stock.inventory"].create( + { + "name": "Adjust +1000L on X1", + "location_ids": [(4, self.location_fl1.id)], + "product_ids": [(4, self.product.id)], + } + ) + inventory.action_start() + inv_line = inventory.line_ids.filtered( + lambda l: l.location_id == self.location_fl1 + ) + inv_line[0].product_qty = inv_line[0].product_qty + 1000 + inventory.action_validate() + self.assertEqual( + self._get_positive_quantity(self.location_fl1, self.product), 8000 + ) + + # Sale 3: 600 L of X1, reserved then unreserved + sale_picking_3 = self._create_sale_picking( + self.location_fl1, + self.product, + "Sale 3 - X1 600L", + 600, + unreserve=True, + ) + self.assertEqual(sale_picking_3.state, "confirmed") + + # Verify no reserved quantities remain at FL1 + quants = self._get_location_quants(self.location_fl1, self.product) + self.assertFalse(any(q.reserved_quantity > 0 for q in quants)) + self.assertFalse(self.location_fl1.flowable_blocked) + + # ACT + lot_p1 = self.env["stock.production.lot"].create( + { + "name": "P1", + "product_id": self.product.id, + } + ) + self._receive_stock(self.location_fl1, self.product, lot_p1, 5000) + + # ASSERT + production = self._find_flowable_production(self.location_fl1) + self.assertTrue(production, "A mixing MO should have been created") + self.assertTrue( + self.location_fl1.flowable_blocked, + "FL1 should be blocked after reception triggers a mixing MO", + ) From 78b8ccf38839cab51e3345993af1ab58e832eff2 Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Fri, 27 Feb 2026 20:55:03 +0100 Subject: [PATCH 16/31] [REF] stock_location_flowable: improve stock move write readability Restructure the write method for clarity: early return for non-flowable operations, separate state checks into disjoint branches, and skip iterations when state is not changing. --- stock_location_flowable/models/stock_move.py | 29 ++++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/stock_location_flowable/models/stock_move.py b/stock_location_flowable/models/stock_move.py index 689224c29..59e6843e7 100644 --- a/stock_location_flowable/models/stock_move.py +++ b/stock_location_flowable/models/stock_move.py @@ -16,10 +16,12 @@ def _trigger_assign(self): def write(self, vals): for rec in self: production = rec.raw_material_production_id + if not production.picking_type_id.flowable_operation: + continue new_state = vals.get("state") + # Guard: block all modifications during active mixing if ( - production.picking_type_id.flowable_operation - and production.picking_id + production.picking_id and production.state == "to_close" and new_state != "done" ): @@ -29,15 +31,18 @@ def write(self, vals): " The mixing is in progress." ) ) - elif ( - new_state in ("confirmed", "assigned", "partially_available") - and vals.get("move_line_ids", rec.move_line_ids) - and production.picking_type_id.flowable_operation - and production.location_dest_id.flowable_storage - and not production.location_dest_id.flowable_blocked - ): - production.location_dest_id.flowable_production_id = production - elif production.location_dest_id.flowable_production_id == production: - if new_state in ("cancel", "done"): + if not new_state: + continue + # Block location when MO raw materials are fully reserved + if new_state == "assigned": + if ( + vals.get("move_line_ids", rec.move_line_ids) + and production.location_dest_id.flowable_storage + and not production.location_dest_id.flowable_blocked + ): + production.location_dest_id.flowable_production_id = production + # Unblock location when MO raw materials are done or cancelled + elif new_state in ("cancel", "done"): + if production.location_dest_id.flowable_production_id == production: production.location_dest_id.flowable_production_id = False return super().write(vals) From e1251f2f4cd6a0ba30e1ba684389d4f2d0b94817 Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Fri, 27 Feb 2026 20:07:31 +0100 Subject: [PATCH 17/31] [IMP] stock_location_flowable: add blocking lifecycle tests Add tests covering the full flowable location blocking lifecycle: reception blocking, second reception rejection, full cycle (block/unblock), per-location isolation, auto-lot assignment, and non-flowable location passthrough. --- .../tests/test_stock_picking.py | 263 ++++++++++++++++++ 1 file changed, 263 insertions(+) diff --git a/stock_location_flowable/tests/test_stock_picking.py b/stock_location_flowable/tests/test_stock_picking.py index eedd03514..1bec83e72 100644 --- a/stock_location_flowable/tests/test_stock_picking.py +++ b/stock_location_flowable/tests/test_stock_picking.py @@ -648,3 +648,266 @@ def test_auto_lot_creation_with_sequence(self): # ASSERT self.assertTrue(production.lot_producing_id) self.assertNotEqual(production.lot_producing_id, lot) + + def _create_incoming_picking(self, location, product, lot, qty): + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_incoming_1.id, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": location.id, + } + ) + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": product.id, + "product_uom_id": product.uom_id.id, + "lot_id": lot.id, + "qty_done": qty, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": location.id, + "company_id": self.env.company.id, + } + ) + return picking + + def test_reception_blocks_flowable_location(self): + """ + Test that validating a reception to a flowable location blocks it + and creates a manufacturing order linked to the location. + + PRE: - A flowable location with no active production + ACT: - Validate an incoming picking to that location + POST: - The location is blocked (flowable_blocked is True) + - A manufacturing order is linked to the location + - The MO is linked back to the picking + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + self.assertFalse(self.location_flowable_1.flowable_blocked) + + lot = self.env["stock.production.lot"].create( + { + "name": "TEST-BLOCK-LOT", + "product_id": self.product_flowable_1.id, + } + ) + picking = self._create_incoming_picking( + self.location_flowable_1, self.product_flowable_1, lot, 10 + ) + + # ACT + picking.button_validate() + + # ASSERT + self.assertTrue(self.location_flowable_1.flowable_blocked) + production = self.location_flowable_1.flowable_production_id + self.assertTrue(production) + self.assertEqual(production.picking_id, picking) + + def test_second_reception_to_blocked_location_rejected(self): + """ + Test that a second reception to a blocked flowable location + is rejected. + + PRE: - A flowable location blocked by a first reception + - A second incoming picking prepared before the location + was blocked + ACT: - Try to validate the second incoming picking + POST: - An error is raised about the location being blocked + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_1 = self.env["stock.production.lot"].create( + { + "name": "TEST-BLOCK-LOT1", + "product_id": self.product_flowable_1.id, + } + ) + first_picking = self._create_incoming_picking( + self.location_flowable_1, self.product_flowable_1, lot_1, 10 + ) + + lot_2 = self.env["stock.production.lot"].create( + { + "name": "TEST-BLOCK-LOT2", + "product_id": self.product_flowable_1.id, + } + ) + second_picking = self._create_incoming_picking( + self.location_flowable_1, self.product_flowable_1, lot_2, 10 + ) + + first_picking.button_validate() + self.assertTrue(self.location_flowable_1.flowable_blocked) + + # ACT & ASSERT + with self.assertRaises(Exception): + second_picking.button_validate() + + def test_reception_after_mo_completed_succeeds(self): + """ + Test the full cycle: reception blocks the location, completing + the MO unblocks it, and a new reception succeeds. + + PRE: - A flowable location with no active production + ACT: - Validate a first reception (location gets blocked) + - Complete the resulting MO (location gets unblocked) + - Validate a second reception + POST: - The location is unblocked after completing the MO + - The second reception succeeds and creates a new MO + - The location is blocked again by the new MO + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_1 = self.env["stock.production.lot"].create( + { + "name": "TEST-CYCLE-LOT1", + "product_id": self.product_flowable_1.id, + } + ) + first_picking = self._create_incoming_picking( + self.location_flowable_1, self.product_flowable_1, lot_1, 10 + ) + + # ACT 1 - First reception blocks the location + first_picking.button_validate() + self.assertTrue(self.location_flowable_1.flowable_blocked) + first_production = self.location_flowable_1.flowable_production_id + + # ACT 2 - Complete the MO, location gets unblocked + first_production.button_mark_done() + self.assertFalse(self.location_flowable_1.flowable_blocked) + + # ACT 3 - Second reception succeeds and blocks again + lot_2 = self.env["stock.production.lot"].create( + { + "name": "TEST-CYCLE-LOT2", + "product_id": self.product_flowable_1.id, + } + ) + second_picking = self._create_incoming_picking( + self.location_flowable_1, self.product_flowable_1, lot_2, 5 + ) + second_picking.button_validate() + + # ASSERT + self.assertTrue(self.location_flowable_1.flowable_blocked) + second_production = self.location_flowable_1.flowable_production_id + self.assertTrue(second_production) + self.assertNotEqual(first_production, second_production) + self.assertEqual(second_production.picking_id, second_picking) + + def test_blocking_is_per_location(self): + """ + Test that blocking one flowable location does not affect another. + + PRE: - Two flowable locations with no active production + ACT: - Validate a reception to location 1 (blocks it) + - Validate a reception to location 2 + POST: - Location 1 is blocked + - Location 2 reception succeeds and blocks location 2 + independently + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_1 = self.env["stock.production.lot"].create( + { + "name": "TEST-PERLOC-LOT1", + "product_id": self.product_flowable_1.id, + } + ) + picking_1 = self._create_incoming_picking( + self.location_flowable_1, self.product_flowable_1, lot_1, 10 + ) + + lot_2 = self.env["stock.production.lot"].create( + { + "name": "TEST-PERLOC-LOT2", + "product_id": self.product_flowable_1.id, + } + ) + picking_2 = self._create_incoming_picking( + self.location_flowable_2, self.product_flowable_1, lot_2, 10 + ) + + # ACT + picking_1.button_validate() + self.assertTrue(self.location_flowable_1.flowable_blocked) + self.assertFalse(self.location_flowable_2.flowable_blocked) + + picking_2.button_validate() + + # ASSERT + self.assertTrue(self.location_flowable_1.flowable_blocked) + self.assertTrue(self.location_flowable_2.flowable_blocked) + self.assertNotEqual( + self.location_flowable_1.flowable_production_id, + self.location_flowable_2.flowable_production_id, + ) + + def test_auto_lot_location_also_gets_blocked(self): + """ + Test that a flowable location with flowable_create_lots=True + also gets blocked after a reception. + + PRE: - location_flowable_2 has flowable_create_lots=True + ACT: - Validate a reception to location_flowable_2 + POST: - The location is blocked + - The MO uses an auto-generated lot (different from the + incoming lot) + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + product = self.location_flowable_2.flowable_allowed_product_ids[0] + + lot = self.env["stock.production.lot"].create( + { + "name": "TEST-AUTOLOT-BLOCK", + "product_id": product.id, + } + ) + picking = self._create_incoming_picking( + self.location_flowable_2, product, lot, 50 + ) + + # ACT + picking.button_validate() + + # ASSERT + self.assertTrue(self.location_flowable_2.flowable_blocked) + production = self.location_flowable_2.flowable_production_id + self.assertTrue(production) + self.assertNotEqual(production.lot_producing_id, lot) + + def test_non_flowable_location_not_affected(self): + """ + Test that receiving stock at a non-flowable location does not + trigger any blocking or MO creation. + + PRE: - A regular (non-flowable) internal location + ACT: - Validate an incoming picking to that location + POST: - No flowable_production_id is set + - No MO is created for the picking + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + product = self.product_flowable_1 + lot = self.env["stock.production.lot"].create( + { + "name": "TEST-NONFLOW-LOT", + "product_id": product.id, + } + ) + picking = self._create_incoming_picking(self.location_1, product, lot, 10) + + # ACT + picking.button_validate() + + # ASSERT + self.assertFalse(self.location_1.flowable_storage) + self.assertFalse(picking.flowable_production_ids) From 18adb3974c31390587935e04b6a2438624afd027 Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Fri, 27 Feb 2026 20:08:19 +0100 Subject: [PATCH 18/31] [IMP] stock_location_flowable: add flowable blocking technical documentation Add comprehensive technical documentation explaining the flowable location blocking mechanism, including SVG diagrams covering the full lifecycle: reception, blocking, second reception rejection, MO completion, cancellation, internal transfers, and reservation conflicts. --- .../doc/diagrams/00_tank_lifecycle.svg | 84 +++++ .../doc/diagrams/01_first_reception.svg | 58 ++++ .../diagrams/02_second_reception_blocked.svg | 73 +++++ .../03_second_reception_not_blocked.svg | 74 +++++ .../doc/diagrams/04_mo_completion.svg | 68 +++++ .../doc/diagrams/05_mo_cancellation.svg | 58 ++++ .../doc/diagrams/06_internal_transfer.svg | 59 ++++ .../doc/diagrams/07_proposed_fix.svg | 80 +++++ .../08_reception_blocked_by_reservations.svg | 87 ++++++ .../doc/diagrams/09_unreserve_and_retry.svg | 83 +++++ .../doc/flowable_location_blocking_flow.md | 287 ++++++++++++++++++ 11 files changed, 1011 insertions(+) create mode 100644 stock_location_flowable/doc/diagrams/00_tank_lifecycle.svg create mode 100644 stock_location_flowable/doc/diagrams/01_first_reception.svg create mode 100644 stock_location_flowable/doc/diagrams/02_second_reception_blocked.svg create mode 100644 stock_location_flowable/doc/diagrams/03_second_reception_not_blocked.svg create mode 100644 stock_location_flowable/doc/diagrams/04_mo_completion.svg create mode 100644 stock_location_flowable/doc/diagrams/05_mo_cancellation.svg create mode 100644 stock_location_flowable/doc/diagrams/06_internal_transfer.svg create mode 100644 stock_location_flowable/doc/diagrams/07_proposed_fix.svg create mode 100644 stock_location_flowable/doc/diagrams/08_reception_blocked_by_reservations.svg create mode 100644 stock_location_flowable/doc/diagrams/09_unreserve_and_retry.svg create mode 100644 stock_location_flowable/doc/flowable_location_blocking_flow.md diff --git a/stock_location_flowable/doc/diagrams/00_tank_lifecycle.svg b/stock_location_flowable/doc/diagrams/00_tank_lifecycle.svg new file mode 100644 index 000000000..e19cde451 --- /dev/null +++ b/stock_location_flowable/doc/diagrams/00_tank_lifecycle.svg @@ -0,0 +1,84 @@ + + + + + + + + + + + + + Tank Lifecycle — One Tank, Four States + This is ONE tank (flowable location) going through states over time. + A reception never reserves — it just delivers stock to the tank. + + + + Tank (flowable location) + e.g. Tank-A, Tank-B + + + + TIME → + + + + AVAILABLE + No active MO + No blocking + Tank may have stock + or be empty + + + + PO reception + picking validated + + + + + BLOCKED 🔒 + Stock arrived + MO created + No one else can receive + + + + MO mixes / + consumes + + + + + PROCESSING + MO consuming stock + Still blocked (MO active) + + + + MO marked + done + + + + AVAILABLE + MO completed + Blocking cleared + Ready for next + PO reception + + + + cycle repeats with next PO reception + + + + picking + + MO + + move.write + diff --git a/stock_location_flowable/doc/diagrams/01_first_reception.svg b/stock_location_flowable/doc/diagrams/01_first_reception.svg new file mode 100644 index 000000000..e7f5ce461 --- /dev/null +++ b/stock_location_flowable/doc/diagrams/01_first_reception.svg @@ -0,0 +1,58 @@ + + + + + + + + + Scenario 1: First PO Reception — Happy Path + One PO received at an available tank. MO auto-created, location blocked, MO completes, location freed. + Everything works correctly. No bugs involved. + + + + + + + + + + + + + + + + + PO #1 Confirmed + creates reception picking + + + Reception #1 Validated + MO created, location BLOCKED + + + MO #1 Processing + mixing / consuming + + + MO #1 Done + location UNBLOCKED + + + + AVAILABLE + + BLOCKED (MO #1) + + AVAILABLE + TANK LOCATION STATE + + + Blocking is set inside stock.move.write() when production.action_assign() changes the MO's raw move state. + Unblocking is set inside stock.move.write() when the MO's raw move goes to "done". + A reception never reserves — it delivers stock from a virtual supplier location to the tank. + diff --git a/stock_location_flowable/doc/diagrams/02_second_reception_blocked.svg b/stock_location_flowable/doc/diagrams/02_second_reception_blocked.svg new file mode 100644 index 000000000..61dd8217b --- /dev/null +++ b/stock_location_flowable/doc/diagrams/02_second_reception_blocked.svg @@ -0,0 +1,73 @@ + + + + + + + + + Scenario 2 — Bug #1: Second PO Reception — Location Blocked but Constraint Bypassed + Location IS blocked. A second PO receives into the same tank. The constraint SKIPS non-MO moves. + The second reception goes through. Two active MOs on the same tank. + + + + + + + + + + + + + + + + + PO #1 Confirmed + creates reception #1 + + + + Reception #1 Validated + MO created, BLOCKED + + + time passes + + + + PO #2 Confirmed + creates reception #2 + + + + Reception #2 GOES THROUGH! + + + constraint skips non-MO moves + MO #2 created — TWO MOs! + + + !! + + + + AVAILABLE + + BLOCKED (MO #1) — but constraint doesn't enforce it for non-MO moves + TANK LOCATION STATE + + + + Bug #1: constraint checked the MOVE's production, not the LOCATION's blocking state + Old code: "if production and location.flowable_production_id != production" — when production is False + (PO reception, internal transfer), the entire check is skipped. Even though the location IS blocked. + + + + Fixed by PR #847: github.com/nuobit/odoo-addons/pull/847 + diff --git a/stock_location_flowable/doc/diagrams/03_second_reception_not_blocked.svg b/stock_location_flowable/doc/diagrams/03_second_reception_not_blocked.svg new file mode 100644 index 000000000..4e5b92439 --- /dev/null +++ b/stock_location_flowable/doc/diagrams/03_second_reception_not_blocked.svg @@ -0,0 +1,74 @@ + + + + + + + + + Scenario 3 — Bug #2: Second PO Reception — Location Not Blocked + First reception's production.action_assign() failed → location was never blocked. Second PO goes through. + Two active MOs on the same tank. This is an example case. + + + + + + + + + + + + + + + + + PO #1 Confirmed + creates reception #1 + + + + Reception #1 Validated + + MO created, NOT BLOCKED + + + 45 days pass + MO #1 never completed + + + + PO #2 Confirmed + creates reception #2 + + + + Reception #2 GOES THROUGH! + + constraint passes (nothing to check) + MO #2 created — TWO MOs! + + !! + + + + AVAILABLE + + SHOULD BE BLOCKED — but isn't (flowable_production_id = NULL) + TANK LOCATION STATE + + + + Bug #2: blocking depends on production.action_assign() success inside picking._action_done() + Blocking is set inside stock.move.write() only when action_assign changes the raw move state. + If reservation fails → state doesn't change → write() not triggered → flowable_production_id stays NULL. + The constraint has nothing to enforce — flowable_blocked is False. + + + + Example: Tank-A — PO/001 (2025-01-15) + PO/002 (2025-03-01) — MO 001 + MO 002 + diff --git a/stock_location_flowable/doc/diagrams/04_mo_completion.svg b/stock_location_flowable/doc/diagrams/04_mo_completion.svg new file mode 100644 index 000000000..718c0970c --- /dev/null +++ b/stock_location_flowable/doc/diagrams/04_mo_completion.svg @@ -0,0 +1,68 @@ + + + + + + + + + Scenario 4: MO Completion — Location Unblocked + A PO was received, MO was created, tank is blocked. When the MO completes, the tank is unblocked for the next reception. + + + + time + + + + + + + + + + + + + + + + PO Received + MO created, tank BLOCKED + + + + MO Processing + mixing / consuming stock + + + + MO Marked as Done + raw move state → "done" + stock.move.write() clears + flowable_production_id = NULL + + + + Ready for Next PO + tank available again + + + + AVAIL + + + BLOCKED (MO active) + + + AVAILABLE + + TANK LOCATION STATE + + + + Mechanism: stock.move.write() override detects raw move going to "done" or "cancel" + → clears production.location_dest_id.flowable_production_id (the flowable location) + diff --git a/stock_location_flowable/doc/diagrams/05_mo_cancellation.svg b/stock_location_flowable/doc/diagrams/05_mo_cancellation.svg new file mode 100644 index 000000000..cae08e140 --- /dev/null +++ b/stock_location_flowable/doc/diagrams/05_mo_cancellation.svg @@ -0,0 +1,58 @@ + + + + + + + + + Scenario 5: MO Cancellation — Location Unblocked + Same as Scenario 4, but the MO is cancelled instead of completed. The unblocking mechanism is identical. + + + + time + + + + + + + + + + + + + + PO Received + MO created, tank BLOCKED + + + + MO Cancelled + raw move state → "cancel" → unblocked + + + + Ready for Next PO + tank available again + + + + AVAIL + + + BLOCKED (MO active) + + + AVAILABLE + + TANK LOCATION STATE + + + + Same mechanism as Scenario 4: stock.move.write() detects raw move → "cancel" → clears flowable_production_id + diff --git a/stock_location_flowable/doc/diagrams/06_internal_transfer.svg b/stock_location_flowable/doc/diagrams/06_internal_transfer.svg new file mode 100644 index 000000000..fd9c17472 --- /dev/null +++ b/stock_location_flowable/doc/diagrams/06_internal_transfer.svg @@ -0,0 +1,59 @@ + + + + + + + + + Scenario 6 — Bug #1: Internal Transfer to Blocked Location — Constraint Bypassed + Location IS blocked by an MO. An internal transfer moves stock to the same tank. + The constraint SKIPS the transfer (non-MO move). Stock arrives at the blocked tank. + + + + + + + + + + + + + + + PO Received + MO created, location BLOCKED + + + + MO Processing + tank still blocked + + + + Internal Transfer GOES THROUGH! + + constraint skips non-MO moves + + !! + + + + AVAIL + + BLOCKED (MO active) — but constraint doesn't enforce it for non-MO moves + TANK LOCATION STATE + + + + Bug #1: same as Scenario 2 — constraint checks move's production, not location's blocking state + Internal transfer has no production → "if production and ..." is False → constraint entirely skipped. + + + + Fixed by PR #847: github.com/nuobit/odoo-addons/pull/847 + diff --git a/stock_location_flowable/doc/diagrams/07_proposed_fix.svg b/stock_location_flowable/doc/diagrams/07_proposed_fix.svg new file mode 100644 index 000000000..826634de4 --- /dev/null +++ b/stock_location_flowable/doc/diagrams/07_proposed_fix.svg @@ -0,0 +1,80 @@ + + + + + + + + + Scenario 7 — Fix: Post-check in action_assign Ensures Blocking + action_assign() override in mrp.production checks full reservation after super(). + If not fully assigned: UserError with details of who holds reservations. Transaction rolls back. + + + + + + + + + + + + + + + + + + + PO #1 Confirmed + creates reception #1 + + + + Reception #1 Validated + + + FIX + action_assign OK → BLOCKED + + + time passes + + + + PO #2 Confirmed + creates reception #2 + + + + Reception #2 REJECTED + location blocked by MO #1 + X + + + + MO #1 Done + unblocked + + + + AVAILABLE + + BLOCKED — action_assign succeeds, post-check passes + + TANK LOCATION STATE + + + + Fix: action_assign() override with _check_flowable_reservation() post-check + After super().action_assign(): if any raw move is not "assigned" → UserError with conflicting reservations. + UserError inside _action_done() rolls back the entire transaction — no stock moved, no MO persisted. + Compare with Scenario 3: without this fix, action_assign fails silently and location is never blocked. + + + + EAFP: try the operation, check the result, roll back if it failed + diff --git a/stock_location_flowable/doc/diagrams/08_reception_blocked_by_reservations.svg b/stock_location_flowable/doc/diagrams/08_reception_blocked_by_reservations.svg new file mode 100644 index 000000000..03ce5647f --- /dev/null +++ b/stock_location_flowable/doc/diagrams/08_reception_blocked_by_reservations.svg @@ -0,0 +1,87 @@ + + + + + + + + + Scenario 8 — Reception Rolled Back: action_assign Fails Due to Reservations + Tank has stock. Internal transfers reserved part of it. PO reception validates, MO created. + action_assign() can't fully reserve → post-check raises UserError → entire transaction rolls back. + + + + + + + + + + + + + + + + + + + Previous MO Done + tank has stock, unblocked + + + + Internal Transfer Created + + reserves 6,949 kg from tank + WH/INT/00001 + + + + Another Transfer Created + + reserves 3,500 kg from tank + WH/INT/00002 + + + + New PO Reception + validates, MO created + + + + ROLLED BACK + + action_assign partial → + - 6,949 kg (INT/00001) + - 3,500 kg (INT/00002) + X + + + + AVAILABLE (not blocked) — has 10,449 kg reserved by outgoing transfers + TANK LOCATION STATE (unchanged — transaction rolled back) + + + + Tank quant state at time of action_assign + + + 22,000 kg available + + + 6,949 kg (INT/00001) + + + 3,500 kg (INT/00002) + + quantity_to_prod = 32,449 — but action_assign can only reserve 22,000 (available) + Post-check detects partial reservation → UserError → entire _action_done() rolls back + + + + User must unreserve INT/00001 and INT/00002 first — see Scenario 9 + diff --git a/stock_location_flowable/doc/diagrams/09_unreserve_and_retry.svg b/stock_location_flowable/doc/diagrams/09_unreserve_and_retry.svg new file mode 100644 index 000000000..12c3833b0 --- /dev/null +++ b/stock_location_flowable/doc/diagrams/09_unreserve_and_retry.svg @@ -0,0 +1,83 @@ + + + + + + + + + Scenario 9 — User Unreserves Outgoing Operations, Retries Reception + After Scenario 8: user goes to the listed operations, unreserves them, and retries the reception. + All quants now available. action_assign() succeeds, post-check passes, location blocked. + + + + + + + + + + + + + + + + + + + Reception ROLLED BACK + partial reservation detected + + + + Unreserve INT/00001 + user unreserves manually + + + + Unreserve INT/00002 + user unreserves manually + + + + Retry Reception + + action_assign OK + post-check passes, BLOCKED + + + + MO Processing + fully reserved + + + + HAS RESERVATIONS — action_assign would fail + + + + ALL AVAILABLE + + + + BLOCKED (MO) + + TANK LOCATION STATE + + + + User workflow after the error + + 1. Read the error message — it lists each operation and reserved quantity + 2. Go to each listed picking/operation → click "Unreserve" to release the quants + 3. Return to the reception picking → click "Validate" again + 4. action_assign() succeeds → post-check passes → location BLOCKED + + + + Old lot reservations become obsolete after the merge — the new lot replaces them all + diff --git a/stock_location_flowable/doc/flowable_location_blocking_flow.md b/stock_location_flowable/doc/flowable_location_blocking_flow.md new file mode 100644 index 000000000..5d3b683b5 --- /dev/null +++ b/stock_location_flowable/doc/flowable_location_blocking_flow.md @@ -0,0 +1,287 @@ +# Flowable Location Blocking Mechanism + +How the blocking mechanism works for flowable locations (tanks): lifecycle, concepts, +and scenarios. + +--- + +## 1. Key Concepts + +### 1.1 What is a flowable location? + +A physical tank (e.g. Tank-A, Tank-B). Identified by `flowable_storage = True` on +`stock.location`. It holds liquid/bulk material tracked by lot. + +### 1.2 Tank lifecycle + +A tank goes through a repeating cycle: + +**Available → Reception fills it → Blocked (MO merges) → MO done → Available** + +The tank is "occupied" the moment new material is poured in. While the MO processes the +merge, nobody else should deliver to that tank. + +![Tank Lifecycle](diagrams/00_tank_lifecycle.svg) + +### 1.3 Blocking vs reservation + +These are two independent concepts: + +- **Blocking** (`flowable_production_id` on `stock.location`): a physical constraint — + "this tank is in use, nobody should pour more material in." It's about physical + occupation. Binary: either blocked or not. + +- **Reservation** (`reserved_quantity` on `stock.quant`): an Odoo inventory mechanism — + "these quants are spoken for by a specific stock move." Multiple operations can + partially reserve quants at the same location simultaneously. + +A tank can be **available (not blocked) but have reserved quants** — this is the normal +case when outgoing operations (sales, internal transfers) have reserved material for +delivery. + +### 1.4 Partial reservation on a tank + +Whether partial reservation makes sense depends on the direction: + +- **Outgoing** (sales, internal transfers): **valid**. You can sell 3,500 kg from a + 35,000 kg tank. The sale reserves those 3,500 kg, the rest remains available for other + sales. + +- **Incoming** (receptions/merges): **invalid**. When new material arrives and a merge + MO is created, the MO must process the **entire** tank content (old + new). If some + quants are reserved by outgoing operations, the MO cannot reserve the full amount. + +### 1.5 Why lot reservations become obsolete after a merge + +This is a critical concept. Consider a tank with lot `LOT-001` (50,000 kg): + +1. Sales SO/123 and SO/456 each reserve 5,000 kg of lot `LOT-001` +2. A new PO reception arrives — merge MO is created +3. The MO consumes ALL quants (including `LOT-001`) and produces a **new lot** `LOT-002` + (55,000 kg = old 50,000 + new 5,000) +4. Lot `LOT-001` still exists in the database but has **zero stock** +5. The reservations by SO/123 and SO/456 for lot `LOT-001` are now **unfulfillable** — + the lot has no stock to deliver + +The reservations were already broken the moment the merge happened. This is why the +system forces the user to deal with existing reservations **before** the merge: those +reservations will become invalid anyway, so the user must consciously decide what to do +(unreserve, cancel, or reassign). + +### 1.6 How blocking fields work + +On `stock.location`: + +- **`flowable_production_id`** (Many2one → `mrp.production`): the MO that has blocked + this location. Set by `stock.move.write()` when `action_assign()` transitions a raw + move to `assigned`/`partially_available`. Cleared when the raw move goes to `done` or + `cancel`. + +- **`flowable_blocked`** (Boolean, computed, not stored): + `bool(flowable_storage and flowable_production_id)`. Convenience field — the real data + is `flowable_production_id`. + +The `not flowable_blocked` guard in `stock.move.write()` prevents a new MO from +overwriting an existing `flowable_production_id`. + +### 1.7 The reservation post-check in action_assign + +`action_assign()` is overridden in `mrp.production`. After `super()`, for flowable +operations it verifies all raw moves are in `assigned` state. If not, it searches for +who holds reservations at the source location and raises `UserError` with details. Since +this runs inside `_action_done()`, the `UserError` rolls back the entire transaction — +no stock moved, no MO persisted. + +This is a post-check (EAFP pattern): try the operation, verify the result, roll back if +it failed. Advantages: + +- **No concurrency window**: checks the actual result, not a prediction +- **Transaction safe**: `UserError` inside `_action_done` rolls back everything +- **Universal**: any caller of `action_assign()` on a flowable MO gets the check + +--- + +## 2. Scenarios + +### Scenario 1: Reception to Empty Tank — Happy Path + +Tank Tank-A is available and empty. A PO reception validates. + +1. `_action_done()` runs — stock moves to the tank +2. Merge MO is created with `quantity_to_prod = sum(all quants)` +3. `action_assign()` reserves all quants — succeeds (nothing else reserved) +4. Post-check passes (all raw moves in `assigned` state) +5. `stock.move.write()` sets `flowable_production_id` → **location BLOCKED** + +![First Reception](diagrams/01_first_reception.svg) + +### Scenario 2: Reception with No Outgoing Reservations — Happy Path + +Tank Tank-B has 32,000 kg from a previous merge. No outgoing operations have reserved +anything. A new PO reception validates. + +Same flow as Scenario 1: `action_assign()` reserves all 37,000 kg (old + new), +post-check passes, location blocked. This is the normal case when there are no pending +deliveries from the tank. + +![Fix — Post-check](diagrams/07_proposed_fix.svg) + +### Scenario 3: Reception with Sale/Transfer Reservations — Error and Rollback + +Tank Tank-B has 32,449 kg of lot `LOT-001`. Two outgoing operations have reserved part +of it: + +- Sale SO/001 → delivery WH/OUT/00001: reserved 6,949 kg of `LOT-001` +- Internal transfer WH/INT/00002: reserved 3,500 kg of `LOT-001` + +Available: 22,000 kg. A new PO reception validates: + +1. `_action_done()` runs — stock moves to the tank (now 37,449 kg total) +2. Merge MO created with `quantity_to_prod = 37,449` (sum of ALL quants) +3. `action_assign()` tries to reserve 37,449 but only 27,000 available +4. Raw move stays `confirmed` (not `assigned`) +5. **Post-check detects partial reservation** → `UserError`: + + ``` + Cannot fully reserve flowable location 'Tank-B' because there + are other reserved quantities. All stock must be available before merging. + + The following operations have reservations that must be unreserved first: + + - Product X: 6,949.17 kg — WH/OUT/00001 (Delivery Orders) + - Product X: 3,500.00 kg — WH/INT/00002 (Internal Transfers) + ``` + +6. **Entire transaction rolls back**: no stock moved, no MO created + +The error tells the user exactly which operations to fix. The user knows these +reservations are for lot `LOT-001`, which will cease to exist after the merge anyway +(see [1.5](#15-why-lot-reservations-become-obsolete-after-a-merge)). + +![Reception Blocked by Reservations](diagrams/08_reception_blocked_by_reservations.svg) + +### Scenario 4: User Unreserves and Retries — Success + +After Scenario 3, the user: + +1. Reads the error — sees the delivery and internal transfer holding reservations +2. Goes to WH/OUT/00001 → clicks "Unreserve" (releases 6,949 kg) +3. Goes to WH/INT/00002 → clicks "Unreserve" (releases 3,500 kg) +4. Returns to the PO reception → clicks "Validate" again +5. `action_assign()` succeeds — all quants available → **location BLOCKED** + +After the merge completes, lot `LOT-001` has zero stock and a new lot exists. The user +can then re-reserve the deliveries with the new lot if needed. + +![Unreserve and Retry](diagrams/09_unreserve_and_retry.svg) + +### Scenario 5: Second Reception to Blocked Location — Correctly Rejected + +Location is blocked by an active MO. A second PO reception tries to validate to the same +tank. The constraint `_check_flowable_location_blocked` on `stock.move.line` detects +`flowable_production_id` is set and the current move doesn't belong to that MO → +`ValidationError`. Reception rejected. + +This also applies to internal transfers — any move to a blocked location is rejected +regardless of origin. + +![Second Reception Blocked](diagrams/02_second_reception_blocked.svg) +![Internal Transfer Blocked](diagrams/06_internal_transfer.svg) + +### Scenario 6: MO Completion — Unblocking + +MO finishes processing. The raw move goes to `done`. `stock.move.write()` detects the +state change and clears `flowable_production_id`. Location is available again for the +next reception. + +![MO Completion](diagrams/04_mo_completion.svg) + +### Scenario 7: MO Cancellation — Unblocking + +Same mechanism as completion. `stock.move.write()` clears `flowable_production_id` when +the raw move goes to `cancel`. + +![MO Cancellation](diagrams/05_mo_cancellation.svg) + +--- + +## 3. Historical Reference: Example Case: Duplicate MOs on Same Tank + +Two PO receptions were validated to the same tank, creating duplicate MOs: + +| MO | Picking | Origin | Created | +| ------ | --------- | ------ | ---------- | +| MO 001 | WH/IN/001 | PO/001 | 2025-01-15 | +| MO 002 | WH/IN/002 | PO/002 | 2025-03-01 | + +MO 001's `action_assign()` failed (0 available quants — all reserved by other +operations) → location never blocked → 45 days later PO/002 received into the same tank +unimpeded. + +Two issues contributed: + +1. The blocked-location constraint in `stock_move_line.py` skipped non-MO moves, so even + blocked locations weren't protected from PO receptions +2. `action_assign()` failed silently when quants were partially reserved, leaving the + location unblocked + +Both are now fixed: + +1. The constraint checks `location.flowable_production_id` directly — any move to a + blocked location is rejected +2. The `action_assign()` override detects failed reservations and raises `UserError` + with details, rolling back the transaction + +--- + +## 4. Future Improvements + +- **Auto-unreserve on reception**: Instead of requiring manual unreservation, + automatically unreserve outgoing operations when receiving at a flowable location. + Deferred to observe how the manual approach works in production first — + auto-unreserving could disrupt planned deliveries without the user being aware. + +--- + +## 5. Validation Queries + +```sql +-- Check current state of a flowable location: +SELECT id, name, flowable_production_id, flowable_storage +FROM stock_location WHERE id = ; + +-- Check active MOs on a flowable location: +SELECT mp.id, mp.name, mp.state, sp.name as picking, mp.create_date +FROM mrp_production mp +LEFT JOIN stock_picking sp ON sp.id = mp.picking_id +WHERE mp.location_src_id = + AND mp.state NOT IN ('done', 'cancel'); + +-- Check reserved quantities at all flowable locations: +SELECT sl.name as location, + round(COALESCE(SUM(sq.quantity), 0)::numeric, 2) as total_qty, + round(COALESCE(SUM(sq.reserved_quantity), 0)::numeric, 2) as reserved, + round((COALESCE(SUM(sq.quantity), 0) + - COALESCE(SUM(sq.reserved_quantity), 0))::numeric, 2) as available +FROM stock_location sl +LEFT JOIN stock_quant sq ON sq.location_id = sl.id AND sq.quantity > 0 +WHERE sl.flowable_storage = true +GROUP BY sl.id, sl.name +HAVING COALESCE(SUM(sq.reserved_quantity), 0) > 0 +ORDER BY sl.name; + +-- Check who is reserving at a specific flowable location: +SELECT sml.product_uom_qty as reserved, + pp.default_code as product, + sm.reference, + sp.name as picking, + mp.name as mo +FROM stock_move_line sml +JOIN stock_move sm ON sm.id = sml.move_id +JOIN product_product pp ON pp.id = sml.product_id +LEFT JOIN stock_picking sp ON sp.id = sm.picking_id +LEFT JOIN mrp_production mp ON mp.id = sm.raw_material_production_id +WHERE sml.location_id = + AND sml.product_uom_qty > 0 + AND sm.state NOT IN ('done', 'cancel', 'draft'); +``` From d9dc99615c33d1fd9eddefa921424bb664a22e6d Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Fri, 27 Feb 2026 20:09:08 +0100 Subject: [PATCH 19/31] [IMP] stock_location_flowable: update metadata and contributors Fix manifest author to match pylintrc-mandatory requirements and update CONTRIBUTORS.rst to proper RST format with Eric Antones. Regenerate README.rst and index.html via oca-gen-addon-readme. --- stock_location_flowable/README.rst | 11 +++++++---- stock_location_flowable/__manifest__.py | 2 +- stock_location_flowable/readme/CONTRIBUTORS.rst | 10 ++++++---- .../static/description/index.html | 12 ++++++++---- 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/stock_location_flowable/README.rst b/stock_location_flowable/README.rst index e8dc60c65..3160ce8fc 100644 --- a/stock_location_flowable/README.rst +++ b/stock_location_flowable/README.rst @@ -46,14 +46,17 @@ Authors ~~~~~~~ * NuoBiT Solutions +* S.L. Contributors ~~~~~~~~~~~~ -- [NuoBiT](https://www.nuobit.com): - - Frank Cespedes - - Deniz Gallo - - Bijaya Kumal +* `NuoBiT `__: + + * Frank Cespedes + * Deniz Gallo + * Bijaya Kumal + * Eric Antones Maintainers ~~~~~~~~~~~ diff --git a/stock_location_flowable/__manifest__.py b/stock_location_flowable/__manifest__.py index 5868e555b..5861f3f7c 100644 --- a/stock_location_flowable/__manifest__.py +++ b/stock_location_flowable/__manifest__.py @@ -6,7 +6,7 @@ "summary": "Customizations that allow organizing, controlling, and" " mixing bulk liquid and solid products in a location", "version": "14.0.1.0.1", - "author": "NuoBiT Solutions", + "author": "NuoBiT Solutions, S.L.", "website": "https://github.com/nuobit/odoo-addons", "category": "Stock", "depends": ["mrp", "uom_rounding_coherence"], diff --git a/stock_location_flowable/readme/CONTRIBUTORS.rst b/stock_location_flowable/readme/CONTRIBUTORS.rst index 289f8529d..c18a29389 100644 --- a/stock_location_flowable/readme/CONTRIBUTORS.rst +++ b/stock_location_flowable/readme/CONTRIBUTORS.rst @@ -1,4 +1,6 @@ -- [NuoBiT](https://www.nuobit.com): - - Frank Cespedes - - Deniz Gallo - - Bijaya Kumal +* `NuoBiT `__: + + * Frank Cespedes + * Deniz Gallo + * Bijaya Kumal + * Eric Antones diff --git a/stock_location_flowable/static/description/index.html b/stock_location_flowable/static/description/index.html index e44b2b124..b7b680fda 100644 --- a/stock_location_flowable/static/description/index.html +++ b/stock_location_flowable/static/description/index.html @@ -399,15 +399,19 @@

    Credits

    Authors

    • NuoBiT Solutions
    • +
    • S.L.

    Contributors

    From b96997b39d8911313cb93770eeda803e143b6171 Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Fri, 27 Feb 2026 20:10:00 +0100 Subject: [PATCH 20/31] [I18N] stock_location_flowable: add Catalan and Spanish translations Add complete Catalan (ca.po) and Spanish (es.po) translation files covering all translatable strings in the module. --- stock_location_flowable/i18n/ca.po | 393 +++++++++++++++++++++++++++++ stock_location_flowable/i18n/es.po | 150 +++++++---- 2 files changed, 496 insertions(+), 47 deletions(-) diff --git a/stock_location_flowable/i18n/ca.po b/stock_location_flowable/i18n/ca.po index 53a071cff..fd5bc009d 100644 --- a/stock_location_flowable/i18n/ca.po +++ b/stock_location_flowable/i18n/ca.po @@ -15,6 +15,23 @@ msgstr "" "Content-Transfer-Encoding: \n" "Plural-Forms: \n" +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "All allowed products must be tracked by lot" +msgstr "Tots els productes permesos han de ser rastrejats per lot" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_allowed_product_ids +msgid "Allowed Products" +msgstr "Productes Permesos" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "Blocked" +msgstr "Bloquejat" + #. module: stock_location_flowable #: code:addons/stock_location_flowable/models/mrp_production.py:0 #, python-format @@ -45,12 +62,388 @@ msgstr "" "\n" "%s" +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_capacity +#: model_terms:ir.ui.view,arch_db:stock_location_flowable.stock_location_view_form +msgid "Capacity" +msgstr "Capacitat" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "Capacity must be greater than 0" +msgstr "La capacitat ha de ser superior a 0" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "Capacity must be greater than capacity occupied %s" +msgstr "La capacitat ha de ser superior a la capacitat ocupada %s" + +#. module: stock_location_flowable +#: model_terms:ir.ui.view,arch_db:stock_location_flowable.stock_location_view_form +msgid "Create Lots" +msgstr "Crear Lots" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_mrp_production__display_name +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__display_name +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_move__display_name +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_move_line__display_name +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking__display_name +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking_type__display_name +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_return_picking__display_name +msgid "Display Name" +msgstr "Nom mostrat" + +#. module: stock_location_flowable +#: model_terms:ir.ui.view,arch_db:stock_location_flowable.stock_location_view_form +msgid "Enable" +msgstr "Habilitar" + +#. module: stock_location_flowable +#: model_terms:ir.ui.view,arch_db:stock_location_flowable.stock_location_view_form +msgid "Flowable" +msgstr "Fluid" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_capacity_occupied +msgid "Flowable Capacity Occupied" +msgstr "Capacitat Fluida Ocupada" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "Flowable Location Warning" +msgstr "Avís d'Ubicació Fluida" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking_type__flowable_operation +msgid "Flowable Operation" +msgstr "Operació Fluida" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_percentage_occupied +msgid "Flowable Percentage Occupied" +msgstr "Percentatge Fluid Ocupat" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_production_id +msgid "Flowable Production" +msgstr "Producció de Fluid" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_storage +msgid "Flowable Storage" +msgstr "Emmagatzematge de Fluid" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_mrp_production__id +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__id +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_move__id +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_move_line__id +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking__id +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking_type__id +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_return_picking__id +msgid "ID" +msgstr "ID" + +#. module: stock_location_flowable +#: model:ir.model,name:stock_location_flowable.model_stock_location +msgid "Inventory Locations" +msgstr "Ubicacions d'inventari" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_blocked_popover +msgid "JSON data for the popover widget" +msgstr "Dades JSON per al widget emergent" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_mrp_production____last_update +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location____last_update +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_move____last_update +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_move_line____last_update +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking____last_update +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking_type____last_update +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_return_picking____last_update +msgid "Last Modified on" +msgstr "Última modificació el" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "Location capacity is full" +msgstr "La capacitat de la ubicació està plena" + +#. module: stock_location_flowable +#: model_terms:ir.ui.view,arch_db:stock_location_flowable.stock_location_view_form +msgid "Manufacturing Order" +msgstr "Ordre de producció" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking__flowable_production_ids +#: model_terms:ir.ui.view,arch_db:stock_location_flowable.view_picking_form +#, python-format +msgid "Manufacturing Orders" +msgstr "Ordres de producció" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "More than one flowable manufacturing picking type in warehouse %s" +msgstr "" +"Més d'un tipus d'operació de fabricació fluida al magatzem %s" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "Not found flowable manufacturing picking type in warehouse %s" +msgstr "" +"No s'ha trobat el tipus d'operació de fabricació fluida al magatzem %s" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "Not found sequence in flowable manufacturing picking type %s" +msgstr "" +"No s'ha trobat seqüència al tipus d'operació de fabricació fluida %s" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking_type.py:0 +#, python-format +msgid "Only manufacturing picking types can be flowable." +msgstr "Només els tipus d'operació de fabricació poden ser fluids." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking_type.py:0 +#, python-format +msgid "Only one picking type can be flowable in a warehouse %s." +msgstr "Només un tipus d'operació pot ser fluid al magatzem %s." + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_mrp_production__picking_id +msgid "Picking" +msgstr "Albarà" + +#. module: stock_location_flowable +#: model:ir.model,name:stock_location_flowable.model_stock_picking_type +msgid "Picking Type" +msgstr "Tipus d'albarà" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "Product %s must be tracked by lot" +msgstr "El producte %s ha de ser rastrejat per lot" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "Product %s not allowed in flowable location %s" +msgstr "Producte %s no permès a la ubicació fluida %s" + +#. module: stock_location_flowable +#: model:ir.model,name:stock_location_flowable.model_stock_move_line +msgid "Product Moves (Stock Move Line)" +msgstr "Moviments de Producte (Stock Move Line)" + +#. module: stock_location_flowable +#: model:ir.model,name:stock_location_flowable.model_mrp_production +msgid "Production Order" +msgstr "Ordre de producció" + +#. module: stock_location_flowable +#: model:ir.model,name:stock_location_flowable.model_stock_return_picking +msgid "Return Picking" +msgstr "Albarà de devolució" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_sequence_id +msgid "Sequence" +msgstr "Seqüència" + +#. module: stock_location_flowable +#: model:ir.model,name:stock_location_flowable.model_stock_move +msgid "Stock Move" +msgstr "Moviment d'inventari" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "" +"The allowed products %s cannot have different Unit of Measure than flowable " +"location %s" +msgstr "" +"Els productes permesos %s no poden tenir una unitat de mesura diferent a la " +"ubicació fluida %s" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_move_line.py:0 +#, python-format +msgid "" +"The location %s is blocked. Probably you need to review the pending " +"manufacturing orders related to this location" +msgstr "" +"La ubicació %s està bloquejada. Probablement necessites revisar les ordres " +"de producció pendents relacionades amb aquesta ubicació" + +#. module: stock_location_flowable +#: model_terms:ir.ui.view,arch_db:stock_location_flowable.stock_location_view_form +msgid "The location is blocked" +msgstr "La ubicació està bloquejada" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "" +"The product %s is measured in %s. You can only assign products that have the " +"allowed unit of measure" +msgstr "" +"El producte %s es mesura en %s. Només pots assignar productes que tinguin la " +"unitat de mesura permesa" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "" +"This location is blocked because it has a manufacturing order assigned." +msgstr "" +"Aquesta ubicació està bloquejada perquè té una ordre de producció assignada." + +#. module: stock_location_flowable +#: model:ir.model,name:stock_location_flowable.model_stock_picking +msgid "Transfer" +msgstr "Albarà" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_uom_id +msgid "Unit of Measure" +msgstr "Unitat de Mesura" + #. module: stock_location_flowable #: code:addons/stock_location_flowable/models/mrp_production.py:0 #, python-format msgid "Unknown origin (move %s)" msgstr "Origen desconegut (moviment %s)" +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "" +"You can only receive one product at location %s because a manufacturing " +"order must be generated and the location will be blocked. Create a partial " +"delivery for this product %s." +msgstr "" +"Només pots rebre un producte a la ubicació %s perquè s'ha de generar una " +"ordre de producció i la ubicació quedarà bloquejada. Crea un lliurament " +"parcial per a aquest producte %s." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/mrp_production.py:0 +#, python-format +msgid "" +"You cannot cancel a production with a picking associated. The mixing is in " +"progress." +msgstr "" +"No es pot cancel·lar una producció que tingui un albarà associat. La barreja " +"està en curs." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "" +"You cannot convert this location into a flowable location because there are " +"products with different units of measure." +msgstr "" +"No pots convertir aquesta ubicació en una ubicació fluida perquè hi ha " +"productes amb diferents unitats de mesura." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "" +"You cannot convert this location into a flowable location because there are " +"unmixed products." +msgstr "" +"No pots convertir aquesta ubicació en una ubicació fluida perquè hi ha " +"productes sense barrejar." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "You cannot disable flowable storage from a blocked location." +msgstr "" +"No pots deshabilitar l'emmagatzematge fluid d'una ubicació bloquejada." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_quant.py:0 +#, python-format +msgid "You cannot have more than one lot in the same location." +msgstr "No es pot tenir més d'un lot a la mateixa ubicació." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/mrp_production.py:0 +#, python-format +msgid "" +"You cannot modify a mix production with a picking associated. The mixing is " +"in progress." +msgstr "" +"No es pot modificar una producció de barreja que tingui un albarà associat. " +"La barreja està en curs." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_move.py:0 +#, python-format +msgid "" +"You cannot modify a production with a picking associated. The mixing is in " +"progress." +msgstr "" +"No es pot modificar una producció que tingui un albarà associat. La barreja " +"està en curs." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "" +"You cannot remove a product that is currently stored in this location." +msgstr "" +"No pots eliminar un producte que estigui emmagatzemat en aquesta ubicació." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_return_picking.py:0 +#, python-format +msgid "" +"You cannot return the following products because they come from a flowable " +"location: %s" +msgstr "" +"No pots retornar els següents productes perquè provenen d'una ubicació " +"fluida: %s" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "You have stock movements with different unit of measure" +msgstr "Tens moviments d'estoc amb diferent unitat de mesura" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "You must select a sequence" +msgstr "Has de seleccionar una seqüència" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "You must select a unit of measure" +msgstr "Has de seleccionar una unitat de mesura" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "You must select products" +msgstr "Has de seleccionar productes" + #. module: stock_location_flowable #: code:addons/stock_location_flowable/models/mrp_production.py:0 #, python-format diff --git a/stock_location_flowable/i18n/es.po b/stock_location_flowable/i18n/es.po index e94f2df02..ae04ccf37 100644 --- a/stock_location_flowable/i18n/es.po +++ b/stock_location_flowable/i18n/es.po @@ -199,14 +199,9 @@ msgstr "Ordenes de producción" #. module: stock_location_flowable #: code:addons/stock_location_flowable/models/stock_picking.py:0 #, python-format -msgid "More than one manufacturing code in picking type for flowable location %s" -msgstr "Más de un código de fabricación en el tipo de operación para la ubicación fluida %s" - -#. module: stock_location_flowable -#: code:addons/stock_location_flowable/models/stock_picking.py:0 -#, python-format -msgid "Not found sequence in manufacturing picking type %s for flowable location %s" -msgstr "Secuencia no encontrada en el tipo de operación de fabricación %s para la ubicación fluida %s" +msgid "More than one flowable manufacturing picking type in warehouse %s" +msgstr "" +"Más de un tipo de operación de fabricación fluida en el almacén %s" #. module: stock_location_flowable #: model_terms:ir.actions.act_window,help:stock_location_flowable.action_picking_tree_blocked @@ -216,15 +211,27 @@ msgstr "No se encontró ninguna transferencia. ¡Creemos uno!" #. module: stock_location_flowable #: code:addons/stock_location_flowable/models/stock_picking.py:0 #, python-format -msgid "Not found manufacturing picking type for flowable location %s to do flowable" -" mixing in %s" -msgstr "No se encontró el tipo de operación de producción para la ubicación fluida" -" %s para realizar la mezcla fluida en %s" +msgid "Not found flowable manufacturing picking type in warehouse %s" +msgstr "" +"No se encontró el tipo de operación de fabricación fluida en el almacén %s" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "Not found sequence in flowable manufacturing picking type %s" +msgstr "" +"No se encontró secuencia en el tipo de operación de fabricación fluida %s" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking_type.py:0 +#, python-format +msgid "Only manufacturing picking types can be flowable." +msgstr "Solo los tipos de operación de fabricación pueden ser fluidos." #. module: stock_location_flowable #: code:addons/stock_location_flowable/models/stock_picking_type.py:0 #, python-format -msgid "Only one picking type can be flowable in a warehouse %s.." +msgid "Only one picking type can be flowable in a warehouse %s." msgstr "Sólo un tipo de operación puede ser fluido en el almacén %s." #. module: stock_location_flowable @@ -237,12 +244,6 @@ msgstr "Albarán" msgid "Picking Type" msgstr "Tipo de albarán" -#. module: stock_location_flowable -#: code:addons/stock_location_flowable/models/stock_picking.py:0 -#, python-format -msgid "The allowed products %s cannot have different Unit of Measure than flowable location %s" -msgstr "Los productos permitidos %s no pueden tener una unidad de medida diferente a la ubicación fluida %s" - #. module: stock_location_flowable #: code:addons/stock_location_flowable/models/stock_picking.py:0 #, python-format @@ -280,13 +281,25 @@ msgstr "Secuencia" msgid "Stock Move" msgstr "Movimiento de inventario" +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "" +"The allowed products %s cannot have different Unit of Measure than flowable " +"location %s" +msgstr "" +"Los productos permitidos %s no pueden tener una unidad de medida diferente a " +"la ubicación fluida %s" + #. module: stock_location_flowable #: code:addons/stock_location_flowable/models/stock_move_line.py:0 #, python-format -msgid "The location %s is blocked. Probably you need to review the pending " +msgid "" +"The location %s is blocked. Probably you need to review the pending " "manufacturing orders related to this location" -msgstr "La ubicación %s está bloqueada. Probablemente necesites revisar las" -" órdenes de producción pendientes relacionadas con esta ubicación" +msgstr "" +"La ubicación %s está bloqueada. Probablemente necesites revisar las " +"órdenes de producción pendientes relacionadas con esta ubicación" #. module: stock_location_flowable #: model_terms:ir.ui.view,arch_db:stock_location_flowable.stock_location_view_form @@ -296,16 +309,20 @@ msgstr "La ubicación está bloqueada." #. module: stock_location_flowable #: code:addons/stock_location_flowable/models/stock_location.py:0 #, python-format -msgid "The product %s is measured in %s. You can only assign products that have the" -" allowed unit of measure" -msgstr "El producto %s se mide en %s. Sólo puedes asignar productos que tengan la" -" unidad de medida permitida" +msgid "" +"The product %s is measured in %s. You can only assign products that have the " +"allowed unit of measure" +msgstr "" +"El producto %s se mide en %s. Sólo puedes asignar productos que tengan la " +"unidad de medida permitida" #. module: stock_location_flowable #: code:addons/stock_location_flowable/models/stock_location.py:0 #, python-format -msgid "This location is blocked because it has a manufacturing order assigned." -msgstr "Esta ubicación está bloqueada porque tiene una orden de producción asignada." +msgid "" +"This location is blocked because it has a manufacturing order assigned." +msgstr "" +"Esta ubicación está bloqueada porque tiene una orden de producción asignada." #. module: stock_location_flowable #: model:ir.model,name:stock_location_flowable.model_stock_picking @@ -331,32 +348,51 @@ msgstr "Origen desconocido (movimiento %s)" #. module: stock_location_flowable #: code:addons/stock_location_flowable/models/stock_picking.py:0 #, python-format -msgid "You can only receive one product at location %s because a manufacturing " +msgid "" +"You can only receive one product at location %s because a manufacturing " "order must be generated and the location will be blocked. Create a partial " "delivery for this product %s." -msgstr "Solo puedes recibir un producto en la ubicación %s porque se debe generar" -" una orden de producción y la ubicación será bloqueada. Crea una entrega parcial" -" para este producto %s." +msgstr "" +"Solo puedes recibir un producto en la ubicación %s porque se debe generar " +"una orden de producción y la ubicación será bloqueada. Crea una entrega " +"parcial para este producto %s." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/mrp_production.py:0 +#, python-format +msgid "" +"You cannot cancel a production with a picking associated. The mixing is in " +"progress." +msgstr "" +"No se puede cancelar una producción que tenga un albarán asociado. La mezcla " +"está en curso." #. module: stock_location_flowable #: code:addons/stock_location_flowable/models/stock_location.py:0 #, python-format -msgid "You cannot disable flowable storage from a blocked location." -msgstr "No puedes deshabilitar el almacenamiento fluido de una ubicación bloqueada" +msgid "" +"You cannot convert this location into a flowable location because there are " +"products with different units of measure." +msgstr "" +"No puedes convertir esta ubicación en una ubicación fluida porque hay " +"productos con diferentes unidades de medida." #. module: stock_location_flowable -#: code:addons/stock_location_flowable/models/mrp_production.py:0 +#: code:addons/stock_location_flowable/models/stock_location.py:0 #, python-format -msgid "You cannot cancel a production with a picking associated. The mixing is in progress." -msgstr "No se puede cancelar una producción que tenga un albarán asociado. La mezcla está en curso." +msgid "" +"You cannot convert this location into a flowable location because there are " +"unmixed products." +msgstr "" +"No puedes convertir esta ubicación en una ubicación fluida porque hay " +"productos sin mezclar." #. module: stock_location_flowable #: code:addons/stock_location_flowable/models/stock_location.py:0 #, python-format -msgid "You cannot convert this location into a flowable location because there are " -"unmixed products or products with different units of measure." -msgstr "No puedes convertir esta ubicación en una ubicación fluida porque hay " -"productos sin mezclar o productos con diferentes unidades de medida." +msgid "You cannot disable flowable storage from a blocked location." +msgstr "" +"No puedes deshabilitar el almacenamiento fluido de una ubicación bloqueada" #. module: stock_location_flowable #: code:addons/stock_location_flowable/models/stock_quant.py:0 @@ -366,22 +402,42 @@ msgstr "No se puede tener más de un lote en la misma ubicación." #. module: stock_location_flowable #: code:addons/stock_location_flowable/models/mrp_production.py:0 +#, python-format +msgid "" +"You cannot modify a mix production with a picking associated. The mixing is " +"in progress." +msgstr "" +"No se puede modificar una producción de mezcla que tenga un albarán " +"asociado. La mezcla está en curso." + +#. module: stock_location_flowable #: code:addons/stock_location_flowable/models/stock_move.py:0 #, python-format -msgid "You cannot modify a mix production with a picking associated. The mixing is in progress." -msgstr "No se puede modificar una producción de mezcla que tenga un albarán asociado. La mezcla está en curso." +msgid "" +"You cannot modify a production with a picking associated. The mixing is in " +"progress." +msgstr "" +"No se puede modificar una producción que tenga un albarán asociado. La " +"mezcla está en curso." #. module: stock_location_flowable #: code:addons/stock_location_flowable/models/stock_location.py:0 #, python-format -msgid "You cannot remove a product that is currently stored in this location." -msgstr "No puedes eliminar un producto que esté actualmente almacenado en esta ubicación." +msgid "" +"You cannot remove a product that is currently stored in this location." +msgstr "" +"No puedes eliminar un producto que esté actualmente almacenado en esta " +"ubicación." #. module: stock_location_flowable #: code:addons/stock_location_flowable/models/stock_return_picking.py:0 #, python-format -msgid "You cannot return the product %s because it comes from a flowable location %s." -msgstr "No puedes devolver el producto %s porque proviene de una ubicación fluida %s." +msgid "" +"You cannot return the following products because they come from a flowable " +"location: %s" +msgstr "" +"No puedes devolver los siguientes productos porque provienen de una " +"ubicación fluida: %s" #. module: stock_location_flowable #: code:addons/stock_location_flowable/models/stock_location.py:0 From 350d6210a69165ea694c070df23ad7478aaccd81 Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Fri, 27 Feb 2026 22:43:22 +0100 Subject: [PATCH 21/31] [IMP] stock_location_flowable: validate quant count before creating flowable MO Add a safety check that verifies the number of positive quants at a flowable location before creating the manufacturing order. On initial reception (empty tank) there must be exactly 1 quant. On mixing (different lot arrives) there must be exactly 2 quants, one for each lot. This catches data inconsistencies early instead of letting the MO be created with wrong inputs. --- stock_location_flowable/i18n/ca.po | 30 ++++++++++++++++ stock_location_flowable/i18n/es.po | 31 ++++++++++++++++ .../models/stock_picking.py | 36 +++++++++++++++++++ 3 files changed, 97 insertions(+) diff --git a/stock_location_flowable/i18n/ca.po b/stock_location_flowable/i18n/ca.po index fd5bc009d..27007c513 100644 --- a/stock_location_flowable/i18n/ca.po +++ b/stock_location_flowable/i18n/ca.po @@ -80,6 +80,16 @@ msgstr "La capacitat ha de ser superior a 0" msgid "Capacity must be greater than capacity occupied %s" msgstr "La capacitat ha de ser superior a la capacitat ocupada %s" +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "" +"Initial reception at flowable location '%s' for product '%s': expected 1 " +"positive quant (empty tank) but found %d." +msgstr "" +"Recepció inicial a la ubicació fluida '%s' per al producte '%s': s'esperava " +"1 quant positiu (tanc buit) però se n'han trobat %d." + #. module: stock_location_flowable #: model_terms:ir.ui.view,arch_db:stock_location_flowable.stock_location_view_form msgid "Create Lots" @@ -195,6 +205,26 @@ msgid "More than one flowable manufacturing picking type in warehouse %s" msgstr "" "Més d'un tipus d'operació de fabricació fluida al magatzem %s" +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "" +"Mixing reception at flowable location '%s' for product '%s': expected 2 " +"positive quants but found %d." +msgstr "" +"Recepció de barreja a la ubicació fluida '%s' per al producte '%s': " +"s'esperaven 2 quants positius però se n'han trobat %d." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "" +"Mixing reception at flowable location '%s' for product '%s': expected " +"exactly 1 quant for the received lot '%s' but found %d." +msgstr "" +"Recepció de barreja a la ubicació fluida '%s' per al producte '%s': " +"s'esperava exactament 1 quant per al lot rebut '%s' però se n'han trobat %d." + #. module: stock_location_flowable #: code:addons/stock_location_flowable/models/stock_picking.py:0 #, python-format diff --git a/stock_location_flowable/i18n/es.po b/stock_location_flowable/i18n/es.po index ae04ccf37..d5983f3b0 100644 --- a/stock_location_flowable/i18n/es.po +++ b/stock_location_flowable/i18n/es.po @@ -87,6 +87,16 @@ msgstr "La capacidad debe ser mayor que la capacidad ocupada %s" msgid "Count Picking Blocked" msgstr "Conteo de Albarán Bloqueado" +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "" +"Initial reception at flowable location '%s' for product '%s': expected 1 " +"positive quant (empty tank) but found %d." +msgstr "" +"Recepción inicial en la ubicación fluida '%s' para el producto '%s': se " +"esperaba 1 quant positivo (tanque vacío) pero se encontraron %d." + #. module: stock_location_flowable #: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__create_lots #: model_terms:ir.ui.view,arch_db:stock_location_flowable.stock_location_view_form @@ -208,6 +218,27 @@ msgstr "" msgid "No transfer found. Let's create one!" msgstr "No se encontró ninguna transferencia. ¡Creemos uno!" +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "" +"Mixing reception at flowable location '%s' for product '%s': expected 2 " +"positive quants but found %d." +msgstr "" +"Recepción de mezcla en la ubicación fluida '%s' para el producto '%s': se " +"esperaban 2 quants positivos pero se encontraron %d." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "" +"Mixing reception at flowable location '%s' for product '%s': expected " +"exactly 1 quant for the received lot '%s' but found %d." +msgstr "" +"Recepción de mezcla en la ubicación fluida '%s' para el producto '%s': se " +"esperaba exactamente 1 quant para el lote recibido '%s' pero se encontraron " +"%d." + #. module: stock_location_flowable #: code:addons/stock_location_flowable/models/stock_picking.py:0 #, python-format diff --git a/stock_location_flowable/models/stock_picking.py b/stock_location_flowable/models/stock_picking.py index fe0e3e196..5867c2e5c 100644 --- a/stock_location_flowable/models/stock_picking.py +++ b/stock_location_flowable/models/stock_picking.py @@ -189,6 +189,9 @@ def _action_done(self): ("company_id", "=", rec.company_id.id), ] ) + rec._check_flowable_quant_count( + component_quant, location_dest, product, lot + ) quantity_to_prod = sum(component_quant.mapped("quantity")) production = rec.env["mrp.production"].create( rec._prepare_production_values( @@ -221,3 +224,36 @@ def _action_done(self): production.action_assign() production.qty_producing = quantity_to_prod return res + + def _check_flowable_quant_count(self, quants, location, product, lot): + is_initial = all(q.lot_id == lot for q in quants) + if is_initial: + if len(quants) != 1: + raise UserError( + _( + "Initial reception at flowable location '%s'" + " for product '%s': expected 1 positive quant" + " (empty tank) but found %d." + ) + % (location.name, product.name, len(quants)) + ) + else: + if len(quants) != 2: + raise UserError( + _( + "Mixing reception at flowable location '%s'" + " for product '%s': expected 2 positive quants" + " but found %d." + ) + % (location.name, product.name, len(quants)) + ) + received = quants.filtered(lambda q: q.lot_id == lot) + if len(received) != 1: + raise UserError( + _( + "Mixing reception at flowable location '%s'" + " for product '%s': expected exactly 1 quant" + " for the received lot '%s' but found %d." + ) + % (location.name, product.name, lot.name, len(received)) + ) From f854f246183a9cdb9403c70315577e43b027853b Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Sat, 28 Feb 2026 00:58:54 +0100 Subject: [PATCH 22/31] [REF] stock_location_flowable: consolidate duplicated test helpers into TestCommon Move shared test helpers (_create_lot, _receive_stock, _create_incoming_picking, _find_flowable_production, _get_location_quants, _get_positive_quantity, _seed_flowable_location, _create_inventory_adjustment) from individual test classes into TestCommon. Rebase TestFlowableBlockingWithReservations on TestCommon instead of SavepointCase. Replace all inline duplicated patterns across 8 test files with helper calls. --- stock_location_flowable/tests/test_common.py | 122 +++++- .../tests/test_mrp_production.py | 402 ++++-------------- .../tests/test_stock_location.py | 223 +--------- .../tests/test_stock_move.py | 9 +- .../tests/test_stock_move_line.py | 34 +- .../tests/test_stock_picking.py | 222 ++-------- .../tests/test_stock_quant.py | 33 +- .../tests/test_stock_return_picking.py | 56 +-- 8 files changed, 264 insertions(+), 837 deletions(-) diff --git a/stock_location_flowable/tests/test_common.py b/stock_location_flowable/tests/test_common.py index e0ab5ae96..c0867efeb 100644 --- a/stock_location_flowable/tests/test_common.py +++ b/stock_location_flowable/tests/test_common.py @@ -14,6 +14,8 @@ class TestCommon(common.SavepointCase): def setUpClass(cls): super(TestCommon, cls).setUpClass() + cls.supplier_location = cls.env.ref("stock.stock_location_suppliers") + cls.picking_type_incoming_1 = cls.env["stock.picking.type"].create( { "name": "Receipt1", @@ -145,7 +147,7 @@ def setUpClass(cls): "product_uom_id": cls.product_flowable_1.uom_id.id, "lot_id": lot_1.id, "qty_done": 10, - "location_id": cls.env.ref("stock.stock_location_suppliers").id, + "location_id": cls.supplier_location.id, "location_dest_id": cls.incoming_picking.location_dest_id.id, "company_id": cls.env.company.id, } @@ -166,7 +168,7 @@ def setUpClass(cls): "product_uom_id": cls.product_flowable_1.uom_id.id, "lot_id": lot_1.id, "qty_done": 10, - "location_id": cls.env.ref("stock.stock_location_suppliers").id, + "location_id": cls.supplier_location.id, "location_dest_id": cls.outgoing_picking.location_dest_id.id, "company_id": cls.env.company.id, } @@ -206,3 +208,119 @@ def get_error_message_regex(self, str1): escaped_parts = [re.escape(part) for part in parts] regex_pattern = ".*".join(escaped_parts) return regex_pattern + + def _create_lot(self, product, name): + return self.env["stock.production.lot"].create( + { + "name": name, + "product_id": product.id, + } + ) + + def _receive_stock(self, location, product, lot, qty, picking_type=None): + """Create and validate an incoming picking to any location.""" + if picking_type is None: + picking_type = self.picking_type_incoming_1 + picking = self.env["stock.picking"].create( + { + "picking_type_id": picking_type.id, + "location_id": self.supplier_location.id, + "location_dest_id": location.id, + } + ) + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": product.id, + "product_uom_id": product.uom_id.id, + "lot_id": lot.id, + "qty_done": qty, + "location_id": self.supplier_location.id, + "location_dest_id": location.id, + "company_id": self.env.company.id, + } + ) + picking.button_validate() + return picking + + def _create_incoming_picking(self, location, product, lot, qty, picking_type=None): + """Create an incoming picking WITHOUT validating it.""" + if picking_type is None: + picking_type = self.picking_type_incoming_1 + picking = self.env["stock.picking"].create( + { + "picking_type_id": picking_type.id, + "location_id": self.supplier_location.id, + "location_dest_id": location.id, + } + ) + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": product.id, + "product_uom_id": product.uom_id.id, + "lot_id": lot.id, + "qty_done": qty, + "location_id": self.supplier_location.id, + "location_dest_id": location.id, + "company_id": self.env.company.id, + } + ) + return picking + + def _find_flowable_production(self, location, picking_type=None): + if picking_type is None: + picking_type = self.picking_type_mrp_operation_1 + return self.env["mrp.production"].search( + [ + ("picking_type_id", "=", picking_type.id), + ("location_dest_id", "=", location.id), + ], + order="id desc", + limit=1, + ) + + def _get_location_quants(self, location, product): + return self.env["stock.quant"].search( + [ + ("location_id", "=", location.id), + ("product_id", "=", product.id), + ] + ) + + def _get_positive_quantity(self, location, product): + quants = self._get_location_quants(location, product) + return sum(quants.filtered(lambda q: q.quantity > 0).mapped("quantity")) + + def _seed_flowable_location( + self, location, product, lot, qty, picking_type=None, mrp_picking_type=None + ): + """Receive initial stock and complete the resulting MO.""" + picking = self._receive_stock( + location, product, lot, qty, picking_type=picking_type + ) + production = self._find_flowable_production( + location, picking_type=mrp_picking_type + ) + if production: + production.button_mark_done() + return picking + + def _create_inventory_adjustment(self, location, product, lot, qty): + """Create and validate a simple inventory adjustment (1 lot).""" + inventory = self.env["stock.inventory"].create( + {"name": f"Adjust {product.name} at {location.name}"} + ) + inventory.action_start() + self.env["stock.inventory.line"].create( + { + "inventory_id": inventory.id, + "product_id": product.id, + "product_uom_id": product.uom_id.id, + "location_id": location.id, + "prod_lot_id": lot.id, + "product_qty": qty, + } + ) + inventory.action_validate() + return inventory diff --git a/stock_location_flowable/tests/test_mrp_production.py b/stock_location_flowable/tests/test_mrp_production.py index 2282d99af..8113032f3 100644 --- a/stock_location_flowable/tests/test_mrp_production.py +++ b/stock_location_flowable/tests/test_mrp_production.py @@ -5,7 +5,6 @@ import logging from odoo.exceptions import UserError, ValidationError -from odoo.tests import common from odoo.tools import float_compare, float_round from .test_common import TestCommon @@ -45,71 +44,6 @@ def test_block_new_production_flowable_location_by_outgoing_picking(self): msg_error = self.get_error_message_regex(msg_error) self.assertRegex(error.exception.args[0], msg_error) - def _create_flowable_picking_and_validate(self, location, product, lot, qty): - """Helper to create and validate an incoming picking to a flowable location.""" - return self._receive_stock_at_location(location, product, lot, qty) - - def _receive_stock_at_location( - self, location, product, lot, qty, picking_type=None - ): - """Helper to create and validate an incoming picking to any location.""" - if picking_type is None: - picking_type = self.picking_type_incoming_1 - picking = self.env["stock.picking"].create( - { - "picking_type_id": picking_type.id, - "location_id": self.env.ref("stock.stock_location_suppliers").id, - "location_dest_id": location.id, - } - ) - self.env["stock.move.line"].create( - { - "picking_id": picking.id, - "product_id": product.id, - "product_uom_id": product.uom_id.id, - "lot_id": lot.id, - "qty_done": qty, - "location_id": self.env.ref("stock.stock_location_suppliers").id, - "location_dest_id": location.id, - "company_id": self.env.company.id, - } - ) - picking.button_validate() - return picking - - def _find_flowable_production(self, location): - """Helper to find the latest flowable MO for a location.""" - return self.env["mrp.production"].search( - [ - ("picking_type_id", "=", self.picking_type_mrp_operation_1.id), - ("location_dest_id", "=", location.id), - ], - order="id desc", - limit=1, - ) - - def _get_location_quants(self, location, product): - """Helper to get all quants at a location for a product.""" - return self.env["stock.quant"].search( - [ - ("location_id", "=", location.id), - ("product_id", "=", product.id), - ] - ) - - def _seed_flowable_location(self, location, product, lot, qty): - """Receive initial stock at a flowable location and complete the resulting MO. - - This simulates how a user would put initial stock into a flowable - location: receiving via an incoming picking, which triggers the - creation of a mixing MO, and then completing that MO so the - location is unblocked and ready for the actual test. - """ - self._receive_stock_at_location(location, product, lot, qty) - production = self._find_flowable_production(location) - if production: - production.button_mark_done() - def test_flowable_mixing_produces_single_positive_quant(self): """ Test that after completing a mixing MO, the flowable location has @@ -123,25 +57,15 @@ def test_flowable_mixing_produces_single_positive_quant(self): # ARRANGE self.picking_type_mrp_operation_1.flowable_operation = True - lot_initial = self.env["stock.production.lot"].create( - { - "name": "TEST-INITIAL-LOT", - "product_id": self.product_flowable_1.id, - } - ) + lot_initial = self._create_lot(self.product_flowable_1, "TEST-INITIAL-LOT") self._seed_flowable_location( self.location_flowable_1, self.product_flowable_1, lot_initial, 100 ) - lot_new = self.env["stock.production.lot"].create( - { - "name": "TEST-NEW-LOT", - "product_id": self.product_flowable_1.id, - } - ) + lot_new = self._create_lot(self.product_flowable_1, "TEST-NEW-LOT") # ACT - self._create_flowable_picking_and_validate( + self._receive_stock( self.location_flowable_1, self.product_flowable_1, lot_new, 50 ) production = self._find_flowable_production(self.location_flowable_1) @@ -173,15 +97,10 @@ def test_flowable_no_accumulated_error_after_multiple_mixing_cycles(self): quantities = [50, 30, 45, 25, 20] for i, qty in enumerate(quantities): - lot = self.env["stock.production.lot"].create( - { - "name": f"TEST-MULTI-LOT-{i}", - "product_id": self.product_flowable_1.id, - } - ) + lot = self._create_lot(self.product_flowable_1, f"TEST-MULTI-LOT-{i}") # ACT - self._create_flowable_picking_and_validate( + self._receive_stock( self.location_flowable_1, self.product_flowable_1, lot, qty ) production = self._find_flowable_production(self.location_flowable_1) @@ -216,25 +135,15 @@ def test_flowable_old_quants_go_to_exact_zero(self): # ARRANGE self.picking_type_mrp_operation_1.flowable_operation = True - lot_initial = self.env["stock.production.lot"].create( - { - "name": "TEST-EXACT-ZERO-OLD", - "product_id": self.product_flowable_1.id, - } - ) + lot_initial = self._create_lot(self.product_flowable_1, "TEST-EXACT-ZERO-OLD") self._seed_flowable_location( self.location_flowable_1, self.product_flowable_1, lot_initial, 100 ) - lot_new = self.env["stock.production.lot"].create( - { - "name": "TEST-EXACT-ZERO-NEW", - "product_id": self.product_flowable_1.id, - } - ) + lot_new = self._create_lot(self.product_flowable_1, "TEST-EXACT-ZERO-NEW") # ACT - self._create_flowable_picking_and_validate( + self._receive_stock( self.location_flowable_1, self.product_flowable_1, lot_new, 50 ) production = self._find_flowable_production(self.location_flowable_1) @@ -268,25 +177,15 @@ def test_flowable_mixing_with_fractional_quantities(self): # ARRANGE self.picking_type_mrp_operation_1.flowable_operation = True - lot_initial = self.env["stock.production.lot"].create( - { - "name": "TEST-FRAC-INITIAL", - "product_id": self.product_flowable_1.id, - } - ) + lot_initial = self._create_lot(self.product_flowable_1, "TEST-FRAC-INITIAL") self._seed_flowable_location( self.location_flowable_1, self.product_flowable_1, lot_initial, 33.333 ) - lot_new = self.env["stock.production.lot"].create( - { - "name": "TEST-FRAC-NEW", - "product_id": self.product_flowable_1.id, - } - ) + lot_new = self._create_lot(self.product_flowable_1, "TEST-FRAC-NEW") # ACT - self._create_flowable_picking_and_validate( + self._receive_stock( self.location_flowable_1, self.product_flowable_1, lot_new, 16.667 ) production = self._find_flowable_production(self.location_flowable_1) @@ -312,36 +211,19 @@ def test_flowable_mixing_does_not_affect_non_flowable_quants(self): # ARRANGE self.picking_type_mrp_operation_1.flowable_operation = True - lot_initial = self.env["stock.production.lot"].create( - { - "name": "TEST-NONFLO-INITIAL", - "product_id": self.product_flowable_1.id, - } - ) + lot_initial = self._create_lot(self.product_flowable_1, "TEST-NONFLO-INITIAL") self._seed_flowable_location( self.location_flowable_1, self.product_flowable_1, lot_initial, 100 ) # Stock at non-flowable location - lot_other = self.env["stock.production.lot"].create( - { - "name": "TEST-NONFLO-OTHER", - "product_id": self.product_flowable_1.id, - } - ) - self._receive_stock_at_location( - self.location_1, self.product_flowable_1, lot_other, 200 - ) + lot_other = self._create_lot(self.product_flowable_1, "TEST-NONFLO-OTHER") + self._receive_stock(self.location_1, self.product_flowable_1, lot_other, 200) - lot_new = self.env["stock.production.lot"].create( - { - "name": "TEST-NONFLO-NEW", - "product_id": self.product_flowable_1.id, - } - ) + lot_new = self._create_lot(self.product_flowable_1, "TEST-NONFLO-NEW") # ACT - self._create_flowable_picking_and_validate( + self._receive_stock( self.location_flowable_1, self.product_flowable_1, lot_new, 50 ) production = self._find_flowable_production(self.location_flowable_1) @@ -375,25 +257,15 @@ def test_flowable_qty_producing_is_rounded(self): self.picking_type_mrp_operation_1.flowable_operation = True rounding = self.product_flowable_1.uom_id.rounding - lot_initial = self.env["stock.production.lot"].create( - { - "name": "TEST-ROUND-INITIAL", - "product_id": self.product_flowable_1.id, - } - ) + lot_initial = self._create_lot(self.product_flowable_1, "TEST-ROUND-INITIAL") self._seed_flowable_location( self.location_flowable_1, self.product_flowable_1, lot_initial, 100 ) - lot_new = self.env["stock.production.lot"].create( - { - "name": "TEST-ROUND-NEW", - "product_id": self.product_flowable_1.id, - } - ) + lot_new = self._create_lot(self.product_flowable_1, "TEST-ROUND-NEW") # ACT - self._create_flowable_picking_and_validate( + self._receive_stock( self.location_flowable_1, self.product_flowable_1, lot_new, 50 ) production = self._find_flowable_production(self.location_flowable_1) @@ -473,12 +345,7 @@ def test_flowable_mixing_with_custom_uom_rounding(self): kg_delivery_1 / 1.141, precision_rounding=rounding ) # 576.626 - lot_initial = self.env["stock.production.lot"].create( - { - "name": "TEST-O2-INITIAL", - "product_id": product_o2.id, - } - ) + lot_initial = self._create_lot(product_o2, "TEST-O2-INITIAL") self.picking_type_mrp_operation_1.flowable_operation = True self._seed_flowable_location( @@ -491,18 +358,13 @@ def test_flowable_mixing_with_custom_uom_rounding(self): kg_delivery_2 / 1.141, precision_rounding=rounding ) # 511.139 - lot_new = self.env["stock.production.lot"].create( - { - "name": "TEST-O2-NEW", - "product_id": product_o2.id, - } - ) + lot_new = self._create_lot(product_o2, "TEST-O2-NEW") # ACT: Receive at the cistern picking = self.env["stock.picking"].create( { "picking_type_id": self.picking_type_incoming_1.id, - "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_id": self.supplier_location.id, "location_dest_id": location_cistern.id, } ) @@ -513,7 +375,7 @@ def test_flowable_mixing_with_custom_uom_rounding(self): "product_uom_id": uom_litro_o2.id, "lot_id": lot_new.id, "qty_done": litres_2, - "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_id": self.supplier_location.id, "location_dest_id": location_cistern.id, "company_id": self.env.company.id, } @@ -521,14 +383,7 @@ def test_flowable_mixing_with_custom_uom_rounding(self): picking.button_validate() # Find and complete the mixing MO - production = self.env["mrp.production"].search( - [ - ("picking_type_id", "=", self.picking_type_mrp_operation_1.id), - ("location_dest_id", "=", location_cistern.id), - ], - order="id desc", - limit=1, - ) + production = self._find_flowable_production(location_cistern) self.assertTrue(production, "Mixing MO should have been created") production.button_mark_done() @@ -622,17 +477,12 @@ def test_flowable_mixing_multiple_cycles_fine_rounding(self): for i, kg in enumerate(kg_deliveries): litres = float_round(kg / 1.141, precision_rounding=rounding) - lot = self.env["stock.production.lot"].create( - { - "name": f"TEST-O2-MULTI-{i}", - "product_id": product_o2.id, - } - ) + lot = self._create_lot(product_o2, f"TEST-O2-MULTI-{i}") picking = self.env["stock.picking"].create( { "picking_type_id": self.picking_type_incoming_1.id, - "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_id": self.supplier_location.id, "location_dest_id": location_cistern.id, } ) @@ -643,25 +493,14 @@ def test_flowable_mixing_multiple_cycles_fine_rounding(self): "product_uom_id": uom_litro_o2.id, "lot_id": lot.id, "qty_done": litres, - "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_id": self.supplier_location.id, "location_dest_id": location_cistern.id, "company_id": self.env.company.id, } ) picking.button_validate() - production = self.env["mrp.production"].search( - [ - ( - "picking_type_id", - "=", - self.picking_type_mrp_operation_1.id, - ), - ("location_dest_id", "=", location_cistern.id), - ], - order="id desc", - limit=1, - ) + production = self._find_flowable_production(location_cistern) self.assertTrue( production, f"Mixing MO should be created on delivery {i + 1}" ) @@ -832,33 +671,11 @@ def test_production_without_bom_allowed_for_flowable(self): self.assertTrue(production) -class TestFlowableBlockingWithReservations(common.SavepointCase): +class TestFlowableBlockingWithReservations(TestCommon): @classmethod def setUpClass(cls): super(TestFlowableBlockingWithReservations, cls).setUpClass() - cls.picking_type_incoming = cls.env["stock.picking.type"].create( - { - "name": "TestReceipt", - "sequence_code": "SEQ-TEST-IN", - "code": "incoming", - "default_location_dest_id": cls.env.ref( - "stock.stock_location_locations_partner" - ).id, - } - ) - - cls.picking_type_outgoing = cls.env["stock.picking.type"].create( - { - "name": "TestDelivery", - "sequence_code": "SEQ-TEST-OUT", - "code": "outgoing", - "default_location_src_id": cls.env.ref( - "stock.stock_location_locations_partner" - ).id, - } - ) - cls.picking_type_mrp = cls.env["stock.picking.type"].create( { "name": "TestProduction", @@ -868,16 +685,6 @@ def setUpClass(cls): } ) - cls.product = cls.env["product.product"].create( - { - "name": "TestOxygen", - "type": "product", - "uom_id": cls.env.ref("uom.product_uom_litre").id, - "uom_po_id": cls.env.ref("uom.product_uom_litre").id, - "tracking": "lot", - } - ) - cls.location_fl1 = cls.env["stock.location"].create( { "name": "FL1", @@ -886,63 +693,9 @@ def setUpClass(cls): "flowable_storage": True, "flowable_capacity": 15000, "flowable_uom_id": cls.env.ref("uom.product_uom_litre").id, - "flowable_allowed_product_ids": [(4, cls.product.id)], - } - ) - - cls.supplier_location = cls.env.ref("stock.stock_location_suppliers") - cls.customer_location = cls.env.ref("stock.stock_location_customers") - - def _receive_stock(self, location, product, lot, qty): - picking = self.env["stock.picking"].create( - { - "picking_type_id": self.picking_type_incoming.id, - "location_id": self.supplier_location.id, - "location_dest_id": location.id, - } - ) - self.env["stock.move.line"].create( - { - "picking_id": picking.id, - "product_id": product.id, - "product_uom_id": product.uom_id.id, - "lot_id": lot.id, - "qty_done": qty, - "location_id": self.supplier_location.id, - "location_dest_id": location.id, - "company_id": self.env.company.id, + "flowable_allowed_product_ids": [(4, cls.product_flowable_1.id)], } ) - picking.button_validate() - return picking - - def _find_flowable_production(self, location): - return self.env["mrp.production"].search( - [ - ("picking_type_id", "=", self.picking_type_mrp.id), - ("location_dest_id", "=", location.id), - ], - order="id desc", - limit=1, - ) - - def _get_location_quants(self, location, product): - return self.env["stock.quant"].search( - [ - ("location_id", "=", location.id), - ("product_id", "=", product.id), - ] - ) - - def _get_positive_quantity(self, location, product): - quants = self._get_location_quants(location, product) - return sum(quants.filtered(lambda q: q.quantity > 0).mapped("quantity")) - - def _seed_flowable_location(self, location, product, lot, qty): - self._receive_stock(location, product, lot, qty) - production = self._find_flowable_production(location) - if production: - production.button_mark_done() def _create_sale_picking( self, @@ -953,11 +706,12 @@ def _create_sale_picking( reserve=True, unreserve=False, ): + customer_location = self.env.ref("stock.stock_location_customers") picking = self.env["stock.picking"].create( { - "picking_type_id": self.picking_type_outgoing.id, + "picking_type_id": self.picking_type_outgoing_1.id, "location_id": location.id, - "location_dest_id": self.customer_location.id, + "location_dest_id": customer_location.id, } ) self.env["stock.move"].create( @@ -968,7 +722,7 @@ def _create_sale_picking( "product_uom": product.uom_id.id, "product_uom_qty": qty, "location_id": location.id, - "location_dest_id": self.customer_location.id, + "location_dest_id": customer_location.id, } ) picking.action_confirm() @@ -999,28 +753,30 @@ def test_flowable_blocking_with_pending_reservations_and_reception(self): (the only ones with active reservations) """ # ARRANGE - lot_x1 = self.env["stock.production.lot"].create( - { - "name": "X1", - "product_id": self.product.id, - } + lot_x1 = self._create_lot(self.product_flowable_1, "X1") + self._seed_flowable_location( + self.location_fl1, + self.product_flowable_1, + lot_x1, + 7000, + mrp_picking_type=self.picking_type_mrp, ) - self._seed_flowable_location(self.location_fl1, self.product, lot_x1, 7000) self.assertEqual( - self._get_positive_quantity(self.location_fl1, self.product), 7000 + self._get_positive_quantity(self.location_fl1, self.product_flowable_1), + 7000, ) self.assertFalse(self.location_fl1.flowable_blocked) # Sale 1: 100 L of X1, confirmed + reserved (assigned) sale_picking_1 = self._create_sale_picking( - self.location_fl1, self.product, "Sale 1 - X1 100L", 100 + self.location_fl1, self.product_flowable_1, "Sale 1 - X1 100L", 100 ) self.assertEqual(sale_picking_1.state, "assigned") # Sale 2: 200 L of X1, confirmed only (not reserved) sale_picking_2 = self._create_sale_picking( self.location_fl1, - self.product, + self.product_flowable_1, "Sale 2 - X1 200L", 200, reserve=False, @@ -1032,7 +788,7 @@ def test_flowable_blocking_with_pending_reservations_and_reception(self): { "name": "Adjust +1000L on X1", "location_ids": [(4, self.location_fl1.id)], - "product_ids": [(4, self.product.id)], + "product_ids": [(4, self.product_flowable_1.id)], } ) inventory.action_start() @@ -1042,25 +798,23 @@ def test_flowable_blocking_with_pending_reservations_and_reception(self): inv_line[0].product_qty = inv_line[0].product_qty + 1000 inventory.action_validate() self.assertEqual( - self._get_positive_quantity(self.location_fl1, self.product), 8000 + self._get_positive_quantity(self.location_fl1, self.product_flowable_1), + 8000, ) # Sale 3: 600 L of X1, confirmed + reserved (assigned) sale_picking_3 = self._create_sale_picking( - self.location_fl1, self.product, "Sale 3 - X1 600L", 600 + self.location_fl1, self.product_flowable_1, "Sale 3 - X1 600L", 600 ) self.assertEqual(sale_picking_3.state, "assigned") self.assertFalse(self.location_fl1.flowable_blocked) # ACT - lot_p1 = self.env["stock.production.lot"].create( - { - "name": "P1", - "product_id": self.product.id, - } - ) + lot_p1 = self._create_lot(self.product_flowable_1, "P1") with self.assertRaises(UserError) as error: - self._receive_stock(self.location_fl1, self.product, lot_p1, 5000) + self._receive_stock( + self.location_fl1, self.product_flowable_1, lot_p1, 5000 + ) # ASSERT # Only Sales 1 and 3 appear in the error (the ones with active @@ -1073,10 +827,10 @@ def test_flowable_blocking_with_pending_reservations_and_reception(self): " become invalid.\n\n" "The following operations must be unreserved" " or completed first:\n\n" - " - TestOxygen: 100.0 L (lot X1)" - " - %s (TestDelivery)\n" - " - TestOxygen: 600.0 L (lot X1)" - " - %s (TestDelivery)" + " - ProductFlowable1: 100.0 L (lot X1)" + " - %s (Delivery1)\n" + " - ProductFlowable1: 600.0 L (lot X1)" + " - %s (Delivery1)" ) % (sale_picking_1.name, sale_picking_3.name) self.assertEqual(str(error.exception), expected_msg) @@ -1100,22 +854,24 @@ def test_flowable_merge_succeeds_with_unreserved_operations(self): - A mixing MO is created and FL1 is blocked """ # ARRANGE - lot_x1 = self.env["stock.production.lot"].create( - { - "name": "X1", - "product_id": self.product.id, - } + lot_x1 = self._create_lot(self.product_flowable_1, "X1") + self._seed_flowable_location( + self.location_fl1, + self.product_flowable_1, + lot_x1, + 7000, + mrp_picking_type=self.picking_type_mrp, ) - self._seed_flowable_location(self.location_fl1, self.product, lot_x1, 7000) self.assertEqual( - self._get_positive_quantity(self.location_fl1, self.product), 7000 + self._get_positive_quantity(self.location_fl1, self.product_flowable_1), + 7000, ) self.assertFalse(self.location_fl1.flowable_blocked) # Sale 1: 100 L of X1, reserved then unreserved sale_picking_1 = self._create_sale_picking( self.location_fl1, - self.product, + self.product_flowable_1, "Sale 1 - X1 100L", 100, unreserve=True, @@ -1125,7 +881,7 @@ def test_flowable_merge_succeeds_with_unreserved_operations(self): # Sale 2: 200 L of X1, reserved then unreserved sale_picking_2 = self._create_sale_picking( self.location_fl1, - self.product, + self.product_flowable_1, "Sale 2 - X1 200L", 200, unreserve=True, @@ -1137,7 +893,7 @@ def test_flowable_merge_succeeds_with_unreserved_operations(self): { "name": "Adjust +1000L on X1", "location_ids": [(4, self.location_fl1.id)], - "product_ids": [(4, self.product.id)], + "product_ids": [(4, self.product_flowable_1.id)], } ) inventory.action_start() @@ -1147,13 +903,14 @@ def test_flowable_merge_succeeds_with_unreserved_operations(self): inv_line[0].product_qty = inv_line[0].product_qty + 1000 inventory.action_validate() self.assertEqual( - self._get_positive_quantity(self.location_fl1, self.product), 8000 + self._get_positive_quantity(self.location_fl1, self.product_flowable_1), + 8000, ) # Sale 3: 600 L of X1, reserved then unreserved sale_picking_3 = self._create_sale_picking( self.location_fl1, - self.product, + self.product_flowable_1, "Sale 3 - X1 600L", 600, unreserve=True, @@ -1161,21 +918,18 @@ def test_flowable_merge_succeeds_with_unreserved_operations(self): self.assertEqual(sale_picking_3.state, "confirmed") # Verify no reserved quantities remain at FL1 - quants = self._get_location_quants(self.location_fl1, self.product) + quants = self._get_location_quants(self.location_fl1, self.product_flowable_1) self.assertFalse(any(q.reserved_quantity > 0 for q in quants)) self.assertFalse(self.location_fl1.flowable_blocked) # ACT - lot_p1 = self.env["stock.production.lot"].create( - { - "name": "P1", - "product_id": self.product.id, - } - ) - self._receive_stock(self.location_fl1, self.product, lot_p1, 5000) + lot_p1 = self._create_lot(self.product_flowable_1, "P1") + self._receive_stock(self.location_fl1, self.product_flowable_1, lot_p1, 5000) # ASSERT - production = self._find_flowable_production(self.location_fl1) + production = self._find_flowable_production( + self.location_fl1, picking_type=self.picking_type_mrp + ) self.assertTrue(production, "A mixing MO should have been created") self.assertTrue( self.location_fl1.flowable_blocked, diff --git a/stock_location_flowable/tests/test_stock_location.py b/stock_location_flowable/tests/test_stock_location.py index 556ee3cbf..915314708 100644 --- a/stock_location_flowable/tests/test_stock_location.py +++ b/stock_location_flowable/tests/test_stock_location.py @@ -387,43 +387,11 @@ def test_flowable_capacity_occupied(self): self.picking_type_mrp_operation_1.flowable_operation = True product = self.location_flowable_1.flowable_allowed_product_ids[0] - lot = self.env["stock.production.lot"].create( - { - "name": "TEST-CAP-LOT", - "product_id": product.id, - } - ) - - picking = self.env["stock.picking"].create( - { - "picking_type_id": self.picking_type_incoming_1.id, - "location_id": self.env.ref("stock.stock_location_suppliers").id, - "location_dest_id": self.location_flowable_1.id, - } - ) - self.env["stock.move.line"].create( - { - "picking_id": picking.id, - "product_id": product.id, - "product_uom_id": product.uom_id.id, - "lot_id": lot.id, - "qty_done": 100, - "location_id": self.env.ref("stock.stock_location_suppliers").id, - "location_dest_id": self.location_flowable_1.id, - "company_id": self.env.company.id, - } - ) - picking.button_validate() + lot = self._create_lot(product, "TEST-CAP-LOT") + self._receive_stock(self.location_flowable_1, product, lot, 100) # ACT - production = self.env["mrp.production"].search( - [ - ("picking_type_id", "=", self.picking_type_mrp_operation_1.id), - ("location_dest_id", "=", self.location_flowable_1.id), - ], - order="id desc", - limit=1, - ) + production = self._find_flowable_production(self.location_flowable_1) production.button_mark_done() # ASSERT @@ -536,42 +504,8 @@ def test_cannot_remove_stored_product_from_allowed(self): self.picking_type_mrp_operation_1.flowable_operation = True product = self.location_flowable_1.flowable_allowed_product_ids[0] - lot = self.env["stock.production.lot"].create( - { - "name": "TEST-REMOVE-LOT", - "product_id": product.id, - } - ) - picking = self.env["stock.picking"].create( - { - "picking_type_id": self.picking_type_incoming_1.id, - "location_id": self.env.ref("stock.stock_location_suppliers").id, - "location_dest_id": self.location_flowable_1.id, - } - ) - self.env["stock.move.line"].create( - { - "picking_id": picking.id, - "product_id": product.id, - "product_uom_id": product.uom_id.id, - "lot_id": lot.id, - "qty_done": 50, - "location_id": self.env.ref("stock.stock_location_suppliers").id, - "location_dest_id": self.location_flowable_1.id, - "company_id": self.env.company.id, - } - ) - picking.button_validate() - - production = self.env["mrp.production"].search( - [ - ("picking_type_id", "=", self.picking_type_mrp_operation_1.id), - ("location_dest_id", "=", self.location_flowable_1.id), - ], - order="id desc", - limit=1, - ) - production.button_mark_done() + lot = self._create_lot(product, "TEST-REMOVE-LOT") + self._seed_flowable_location(self.location_flowable_1, product, lot, 50) # ACT & ASSERT with self.assertRaises(UserError) as error: @@ -604,27 +538,9 @@ def test_capacity_full_raises_error(self): self.location_flowable_1.write({"flowable_capacity": 100}) product = self.location_flowable_1.flowable_allowed_product_ids[0] - lot = self.env["stock.production.lot"].create( - {"name": "TEST-FULL-LOT", "product_id": product.id} - ) - picking = self.env["stock.picking"].create( - { - "picking_type_id": self.picking_type_incoming_1.id, - "location_id": self.env.ref("stock.stock_location_suppliers").id, - "location_dest_id": self.location_flowable_1.id, - } - ) - self.env["stock.move.line"].create( - { - "picking_id": picking.id, - "product_id": product.id, - "product_uom_id": product.uom_id.id, - "lot_id": lot.id, - "qty_done": 101, - "location_id": self.env.ref("stock.stock_location_suppliers").id, - "location_dest_id": self.location_flowable_1.id, - "company_id": self.env.company.id, - } + lot = self._create_lot(product, "TEST-FULL-LOT") + picking = self._create_incoming_picking( + self.location_flowable_1, product, lot, 101 ) # ACT & ASSERT @@ -648,39 +564,8 @@ def test_reduce_capacity_below_occupied(self): self.picking_type_mrp_operation_1.flowable_operation = True product = self.location_flowable_1.flowable_allowed_product_ids[0] - lot = self.env["stock.production.lot"].create( - {"name": "TEST-REDUCECAP-LOT", "product_id": product.id} - ) - picking = self.env["stock.picking"].create( - { - "picking_type_id": self.picking_type_incoming_1.id, - "location_id": self.env.ref("stock.stock_location_suppliers").id, - "location_dest_id": self.location_flowable_1.id, - } - ) - self.env["stock.move.line"].create( - { - "picking_id": picking.id, - "product_id": product.id, - "product_uom_id": product.uom_id.id, - "lot_id": lot.id, - "qty_done": 100, - "location_id": self.env.ref("stock.stock_location_suppliers").id, - "location_dest_id": self.location_flowable_1.id, - "company_id": self.env.company.id, - } - ) - picking.button_validate() - - production = self.env["mrp.production"].search( - [ - ("picking_type_id", "=", self.picking_type_mrp_operation_1.id), - ("location_dest_id", "=", self.location_flowable_1.id), - ], - order="id desc", - limit=1, - ) - production.button_mark_done() + lot = self._create_lot(product, "TEST-REDUCECAP-LOT") + self._seed_flowable_location(self.location_flowable_1, product, lot, 100) # ACT & ASSERT with self.assertRaises(ValidationError): @@ -699,39 +584,8 @@ def test_change_uom_with_existing_stock(self): self.picking_type_mrp_operation_1.flowable_operation = True product = self.location_flowable_1.flowable_allowed_product_ids[0] - lot = self.env["stock.production.lot"].create( - {"name": "TEST-UOM-LOT", "product_id": product.id} - ) - picking = self.env["stock.picking"].create( - { - "picking_type_id": self.picking_type_incoming_1.id, - "location_id": self.env.ref("stock.stock_location_suppliers").id, - "location_dest_id": self.location_flowable_1.id, - } - ) - self.env["stock.move.line"].create( - { - "picking_id": picking.id, - "product_id": product.id, - "product_uom_id": product.uom_id.id, - "lot_id": lot.id, - "qty_done": 50, - "location_id": self.env.ref("stock.stock_location_suppliers").id, - "location_dest_id": self.location_flowable_1.id, - "company_id": self.env.company.id, - } - ) - picking.button_validate() - - production = self.env["mrp.production"].search( - [ - ("picking_type_id", "=", self.picking_type_mrp_operation_1.id), - ("location_dest_id", "=", self.location_flowable_1.id), - ], - order="id desc", - limit=1, - ) - production.button_mark_done() + lot = self._create_lot(product, "TEST-UOM-LOT") + self._seed_flowable_location(self.location_flowable_1, product, lot, 50) # Create a new product with units UoM to make the change valid # for the allowed products constraint, isolating _check_flowable_uom_id @@ -772,36 +626,11 @@ def test_convert_location_with_unmixed_products(self): # ARRANGE — add two lots via inventory adjustments product = self.location_flowable_1.flowable_allowed_product_ids[0] - lot_1 = self.env["stock.production.lot"].create( - {"name": "UNMIXED-LOT-1", "product_id": product.id} - ) - lot_2 = self.env["stock.production.lot"].create( - {"name": "UNMIXED-LOT-2", "product_id": product.id} - ) + lot_1 = self._create_lot(product, "UNMIXED-LOT-1") + lot_2 = self._create_lot(product, "UNMIXED-LOT-2") - inventory = self.env["stock.inventory"].create({"name": "Add unmixed stock"}) - inventory.action_start() - self.env["stock.inventory.line"].create( - [ - { - "inventory_id": inventory.id, - "product_id": product.id, - "product_uom_id": product.uom_id.id, - "location_id": self.location_1.id, - "prod_lot_id": lot_1.id, - "product_qty": 50, - }, - { - "inventory_id": inventory.id, - "product_id": product.id, - "product_uom_id": product.uom_id.id, - "location_id": self.location_1.id, - "prod_lot_id": lot_2.id, - "product_qty": 30, - }, - ] - ) - inventory.action_validate() + self._create_inventory_adjustment(self.location_1, product, lot_1, 50) + self._create_inventory_adjustment(self.location_1, product, lot_2, 30) # ACT & ASSERT with self.assertRaises(UserError) as error: @@ -833,24 +662,8 @@ def test_capacity_occupied_zero_for_non_flowable_location(self): # ARRANGE — add stock via inventory adjustment product = self.location_flowable_1.flowable_allowed_product_ids[0] - lot = self.env["stock.production.lot"].create( - {"name": "NONFLO-LOT", "product_id": product.id} - ) - inventory = self.env["stock.inventory"].create( - {"name": "Add stock to non-flowable"} - ) - inventory.action_start() - self.env["stock.inventory.line"].create( - { - "inventory_id": inventory.id, - "product_id": product.id, - "product_uom_id": product.uom_id.id, - "location_id": self.location_1.id, - "prod_lot_id": lot.id, - "product_qty": 200, - } - ) - inventory.action_validate() + lot = self._create_lot(product, "NONFLO-LOT") + self._create_inventory_adjustment(self.location_1, product, lot, 200) # ACT & ASSERT self.location_1.invalidate_cache() diff --git a/stock_location_flowable/tests/test_stock_move.py b/stock_location_flowable/tests/test_stock_move.py index 0347c781a..6fb612d20 100644 --- a/stock_location_flowable/tests/test_stock_move.py +++ b/stock_location_flowable/tests/test_stock_move.py @@ -32,14 +32,7 @@ def test_modify_in_progress_flowable_move_raises_error(self): self.picking_type_mrp_operation_1.flowable_operation = True self.incoming_picking.button_validate() - production = self.env["mrp.production"].search( - [ - ("picking_type_id", "=", self.picking_type_mrp_operation_1.id), - ("location_dest_id", "=", self.location_flowable_1.id), - ], - order="id desc", - limit=1, - ) + production = self._find_flowable_production(self.location_flowable_1) self.assertTrue(production.picking_id) self.assertEqual(production.state, "to_close") diff --git a/stock_location_flowable/tests/test_stock_move_line.py b/stock_location_flowable/tests/test_stock_move_line.py index 91a3ace04..21e3a64b7 100644 --- a/stock_location_flowable/tests/test_stock_move_line.py +++ b/stock_location_flowable/tests/test_stock_move_line.py @@ -55,30 +55,9 @@ def test_blocked_location_rejects_incoming_reception(self): # ARRANGE self.picking_type_mrp_operation_1.flowable_operation = True - lot_2 = self.env["stock.production.lot"].create( - { - "name": "Lot2", - "product_id": self.product_flowable_1.id, - } - ) - second_picking = self.env["stock.picking"].create( - { - "picking_type_id": self.picking_type_incoming_1.id, - "location_id": self.env.ref("stock.stock_location_suppliers").id, - "location_dest_id": self.location_flowable_1.id, - } - ) - self.env["stock.move.line"].create( - { - "picking_id": second_picking.id, - "product_id": self.product_flowable_1.id, - "product_uom_id": self.product_flowable_1.uom_id.id, - "lot_id": lot_2.id, - "qty_done": 10, - "location_id": self.env.ref("stock.stock_location_suppliers").id, - "location_dest_id": self.location_flowable_1.id, - "company_id": self.env.company.id, - } + lot_2 = self._create_lot(self.product_flowable_1, "Lot2") + second_picking = self._create_incoming_picking( + self.location_flowable_1, self.product_flowable_1, lot_2, 10 ) # Block the location by validating the first reception @@ -109,12 +88,7 @@ def test_blocked_location_rejects_internal_transfer(self): # ARRANGE self.picking_type_mrp_operation_1.flowable_operation = True - lot_2 = self.env["stock.production.lot"].create( - { - "name": "LotInternal", - "product_id": self.product_flowable_1.id, - } - ) + lot_2 = self._create_lot(self.product_flowable_1, "LotInternal") transfer_picking = self.env["stock.picking"].create( { "picking_type_id": self.picking_type_internal_1.id, diff --git a/stock_location_flowable/tests/test_stock_picking.py b/stock_location_flowable/tests/test_stock_picking.py index 1bec83e72..5b003c11b 100644 --- a/stock_location_flowable/tests/test_stock_picking.py +++ b/stock_location_flowable/tests/test_stock_picking.py @@ -87,19 +87,9 @@ def test_receiving_one_product_in_flowable_location_incoming_picking(self): self.incoming_picking.action_confirm() - lot_1 = self.env["stock.production.lot"].create( - { - "name": "TEST LMP-0001", - "product_id": self.product_flowable_1.id, - } - ) + lot_1 = self._create_lot(self.product_flowable_1, "TEST LMP-0001") - lot_ch4 = self.env["stock.production.lot"].create( - { - "name": "TEST LMP-0002", - "product_id": self.product_flowable_2.id, - } - ) + lot_ch4 = self._create_lot(self.product_flowable_2, "TEST LMP-0002") self.incoming_picking.move_line_ids = self.env["stock.move.line"].create( { @@ -167,12 +157,7 @@ def test_only_allowed_product_in_incoming_picking(self): self.incoming_picking.action_confirm() - lot_zanahoria = self.env["stock.production.lot"].create( - { - "name": "TEST COD-0001", - "product_id": product_zanahoria.id, - } - ) + lot_zanahoria = self._create_lot(product_zanahoria, "TEST COD-0001") self.incoming_picking.move_line_ids = self.env["stock.move.line"].create( { @@ -234,12 +219,7 @@ def test_different_uom_allowed_product_in_incoming_picking(self): self.incoming_picking.action_confirm() - lot_zanahoria = self.env["stock.production.lot"].create( - { - "name": "TEST COD-0001", - "product_id": product_zanahoria.id, - } - ) + lot_zanahoria = self._create_lot(product_zanahoria, "TEST COD-0001") self.incoming_picking.move_line_ids = self.env["stock.move.line"].create( { @@ -281,12 +261,7 @@ def test_not_found_manufacturing_picking_type_incoming_picking(self): self.incoming_picking.action_confirm() - lot_1 = self.env["stock.production.lot"].create( - { - "name": "TEST LMP-0001", - "product_id": self.product_flowable_1.id, - } - ) + lot_1 = self._create_lot(self.product_flowable_1, "TEST LMP-0001") self.incoming_picking.move_line_ids = self.env["stock.move.line"].create( { @@ -338,12 +313,7 @@ def test_more_than_one_manufacturing_picking_type_incoming_picking(self): ) picking_type_mrp_operation_2.invalidate_cache() - lot_1 = self.env["stock.production.lot"].create( - { - "name": "TEST-DUP-LOT", - "product_id": self.product_flowable_1.id, - } - ) + lot_1 = self._create_lot(self.product_flowable_1, "TEST-DUP-LOT") self.env["stock.move.line"].create( { @@ -352,7 +322,7 @@ def test_more_than_one_manufacturing_picking_type_incoming_picking(self): "product_uom_id": self.product_flowable_1.uom_id.id, "lot_id": lot_1.id, "qty_done": 10, - "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_id": self.supplier_location.id, "location_dest_id": self.incoming_picking.location_dest_id.id, "company_id": self.env.company.id, } @@ -370,12 +340,7 @@ def test_successfull_picking_type_incoming_picking(self): # ARRANGE self.picking_type_mrp_operation_1.flowable_operation = True - lot_1 = self.env["stock.production.lot"].create( - { - "name": "TEST LMP-0001", - "product_id": self.product_flowable_1.id, - } - ) + lot_1 = self._create_lot(self.product_flowable_1, "TEST LMP-0001") self.env["stock.move.line"].create( { @@ -384,7 +349,7 @@ def test_successfull_picking_type_incoming_picking(self): "product_uom_id": self.product_flowable_1.uom_id.id, "lot_id": lot_1.id, "qty_done": 10, - "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_id": self.supplier_location.id, "location_dest_id": self.incoming_picking.location_dest_id.id, "company_id": self.env.company.id, } @@ -407,12 +372,7 @@ def test_action_view_mrp_production_single(self): # ARRANGE self.picking_type_mrp_operation_1.flowable_operation = True - lot_1 = self.env["stock.production.lot"].create( - { - "name": "TEST-ACTION-LOT", - "product_id": self.product_flowable_1.id, - } - ) + lot_1 = self._create_lot(self.product_flowable_1, "TEST-ACTION-LOT") self.env["stock.move.line"].create( { @@ -421,7 +381,7 @@ def test_action_view_mrp_production_single(self): "product_uom_id": self.product_flowable_1.uom_id.id, "lot_id": lot_1.id, "qty_done": 10, - "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_id": self.supplier_location.id, "location_dest_id": self.incoming_picking.location_dest_id.id, "company_id": self.env.company.id, } @@ -450,12 +410,7 @@ def test_action_view_mrp_production_multiple(self): # ARRANGE self.picking_type_mrp_operation_1.flowable_operation = True - lot_1 = self.env["stock.production.lot"].create( - { - "name": "TEST-MULTI-LOT", - "product_id": self.product_flowable_1.id, - } - ) + lot_1 = self._create_lot(self.product_flowable_1, "TEST-MULTI-LOT") self.env["stock.move.line"].create( { @@ -464,7 +419,7 @@ def test_action_view_mrp_production_multiple(self): "product_uom_id": self.product_flowable_1.uom_id.id, "lot_id": lot_1.id, "qty_done": 10, - "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_id": self.supplier_location.id, "location_dest_id": self.incoming_picking.location_dest_id.id, "company_id": self.env.company.id, } @@ -507,12 +462,7 @@ def test_mrp_operation_type_without_sequence_raises_error(self): self.picking_type_mrp_operation_1.flowable_operation = True self.picking_type_mrp_operation_1.sequence_id = False - lot_1 = self.env["stock.production.lot"].create( - { - "name": "TEST-NOSEQ-LOT", - "product_id": self.product_flowable_1.id, - } - ) + lot_1 = self._create_lot(self.product_flowable_1, "TEST-NOSEQ-LOT") self.env["stock.move.line"].create( { @@ -521,7 +471,7 @@ def test_mrp_operation_type_without_sequence_raises_error(self): "product_uom_id": self.product_flowable_1.uom_id.id, "lot_id": lot_1.id, "qty_done": 10, - "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_id": self.supplier_location.id, "location_dest_id": self.incoming_picking.location_dest_id.id, "company_id": self.env.company.id, } @@ -564,27 +514,9 @@ def test_non_lot_tracked_product_at_flowable_location(self): # Change tracking after adding to allowed products (bypasses location constraint) product_nolot.tracking = "none" - lot = self.env["stock.production.lot"].create( - {"name": "TEST-NOLOT", "product_id": product_nolot.id} - ) - picking = self.env["stock.picking"].create( - { - "picking_type_id": self.picking_type_incoming_1.id, - "location_id": self.env.ref("stock.stock_location_suppliers").id, - "location_dest_id": self.location_flowable_1.id, - } - ) - self.env["stock.move.line"].create( - { - "picking_id": picking.id, - "product_id": product_nolot.id, - "product_uom_id": product_nolot.uom_id.id, - "lot_id": lot.id, - "qty_done": 10, - "location_id": self.env.ref("stock.stock_location_suppliers").id, - "location_dest_id": self.location_flowable_1.id, - "company_id": self.env.company.id, - } + lot = self._create_lot(product_nolot, "TEST-NOLOT") + picking = self._create_incoming_picking( + self.location_flowable_1, product_nolot, lot, 10 ) # ACT & ASSERT @@ -610,67 +542,20 @@ def test_auto_lot_creation_with_sequence(self): self.picking_type_mrp_operation_1.flowable_operation = True product = self.location_flowable_2.flowable_allowed_product_ids[0] - lot = self.env["stock.production.lot"].create( - {"name": "TEST-AUTOLOT", "product_id": product.id} - ) - picking = self.env["stock.picking"].create( - { - "picking_type_id": self.picking_type_incoming_1.id, - "location_id": self.env.ref("stock.stock_location_suppliers").id, - "location_dest_id": self.location_flowable_2.id, - } - ) - self.env["stock.move.line"].create( - { - "picking_id": picking.id, - "product_id": product.id, - "product_uom_id": product.uom_id.id, - "lot_id": lot.id, - "qty_done": 50, - "location_id": self.env.ref("stock.stock_location_suppliers").id, - "location_dest_id": self.location_flowable_2.id, - "company_id": self.env.company.id, - } + lot = self._create_lot(product, "TEST-AUTOLOT") + picking = self._create_incoming_picking( + self.location_flowable_2, product, lot, 50 ) # ACT picking.button_validate() - production = self.env["mrp.production"].search( - [ - ("picking_type_id", "=", self.picking_type_mrp_operation_1.id), - ("location_dest_id", "=", self.location_flowable_2.id), - ], - order="id desc", - limit=1, - ) + production = self._find_flowable_production(self.location_flowable_2) # ASSERT self.assertTrue(production.lot_producing_id) self.assertNotEqual(production.lot_producing_id, lot) - def _create_incoming_picking(self, location, product, lot, qty): - picking = self.env["stock.picking"].create( - { - "picking_type_id": self.picking_type_incoming_1.id, - "location_id": self.env.ref("stock.stock_location_suppliers").id, - "location_dest_id": location.id, - } - ) - self.env["stock.move.line"].create( - { - "picking_id": picking.id, - "product_id": product.id, - "product_uom_id": product.uom_id.id, - "lot_id": lot.id, - "qty_done": qty, - "location_id": self.env.ref("stock.stock_location_suppliers").id, - "location_dest_id": location.id, - "company_id": self.env.company.id, - } - ) - return picking - def test_reception_blocks_flowable_location(self): """ Test that validating a reception to a flowable location blocks it @@ -686,12 +571,7 @@ def test_reception_blocks_flowable_location(self): self.picking_type_mrp_operation_1.flowable_operation = True self.assertFalse(self.location_flowable_1.flowable_blocked) - lot = self.env["stock.production.lot"].create( - { - "name": "TEST-BLOCK-LOT", - "product_id": self.product_flowable_1.id, - } - ) + lot = self._create_lot(self.product_flowable_1, "TEST-BLOCK-LOT") picking = self._create_incoming_picking( self.location_flowable_1, self.product_flowable_1, lot, 10 ) @@ -719,22 +599,12 @@ def test_second_reception_to_blocked_location_rejected(self): # ARRANGE self.picking_type_mrp_operation_1.flowable_operation = True - lot_1 = self.env["stock.production.lot"].create( - { - "name": "TEST-BLOCK-LOT1", - "product_id": self.product_flowable_1.id, - } - ) + lot_1 = self._create_lot(self.product_flowable_1, "TEST-BLOCK-LOT1") first_picking = self._create_incoming_picking( self.location_flowable_1, self.product_flowable_1, lot_1, 10 ) - lot_2 = self.env["stock.production.lot"].create( - { - "name": "TEST-BLOCK-LOT2", - "product_id": self.product_flowable_1.id, - } - ) + lot_2 = self._create_lot(self.product_flowable_1, "TEST-BLOCK-LOT2") second_picking = self._create_incoming_picking( self.location_flowable_1, self.product_flowable_1, lot_2, 10 ) @@ -762,12 +632,7 @@ def test_reception_after_mo_completed_succeeds(self): # ARRANGE self.picking_type_mrp_operation_1.flowable_operation = True - lot_1 = self.env["stock.production.lot"].create( - { - "name": "TEST-CYCLE-LOT1", - "product_id": self.product_flowable_1.id, - } - ) + lot_1 = self._create_lot(self.product_flowable_1, "TEST-CYCLE-LOT1") first_picking = self._create_incoming_picking( self.location_flowable_1, self.product_flowable_1, lot_1, 10 ) @@ -782,12 +647,7 @@ def test_reception_after_mo_completed_succeeds(self): self.assertFalse(self.location_flowable_1.flowable_blocked) # ACT 3 - Second reception succeeds and blocks again - lot_2 = self.env["stock.production.lot"].create( - { - "name": "TEST-CYCLE-LOT2", - "product_id": self.product_flowable_1.id, - } - ) + lot_2 = self._create_lot(self.product_flowable_1, "TEST-CYCLE-LOT2") second_picking = self._create_incoming_picking( self.location_flowable_1, self.product_flowable_1, lot_2, 5 ) @@ -814,22 +674,12 @@ def test_blocking_is_per_location(self): # ARRANGE self.picking_type_mrp_operation_1.flowable_operation = True - lot_1 = self.env["stock.production.lot"].create( - { - "name": "TEST-PERLOC-LOT1", - "product_id": self.product_flowable_1.id, - } - ) + lot_1 = self._create_lot(self.product_flowable_1, "TEST-PERLOC-LOT1") picking_1 = self._create_incoming_picking( self.location_flowable_1, self.product_flowable_1, lot_1, 10 ) - lot_2 = self.env["stock.production.lot"].create( - { - "name": "TEST-PERLOC-LOT2", - "product_id": self.product_flowable_1.id, - } - ) + lot_2 = self._create_lot(self.product_flowable_1, "TEST-PERLOC-LOT2") picking_2 = self._create_incoming_picking( self.location_flowable_2, self.product_flowable_1, lot_2, 10 ) @@ -864,12 +714,7 @@ def test_auto_lot_location_also_gets_blocked(self): self.picking_type_mrp_operation_1.flowable_operation = True product = self.location_flowable_2.flowable_allowed_product_ids[0] - lot = self.env["stock.production.lot"].create( - { - "name": "TEST-AUTOLOT-BLOCK", - "product_id": product.id, - } - ) + lot = self._create_lot(product, "TEST-AUTOLOT-BLOCK") picking = self._create_incoming_picking( self.location_flowable_2, product, lot, 50 ) @@ -897,12 +742,7 @@ def test_non_flowable_location_not_affected(self): self.picking_type_mrp_operation_1.flowable_operation = True product = self.product_flowable_1 - lot = self.env["stock.production.lot"].create( - { - "name": "TEST-NONFLOW-LOT", - "product_id": product.id, - } - ) + lot = self._create_lot(product, "TEST-NONFLOW-LOT") picking = self._create_incoming_picking(self.location_1, product, lot, 10) # ACT diff --git a/stock_location_flowable/tests/test_stock_quant.py b/stock_location_flowable/tests/test_stock_quant.py index fa97455f6..58622e63b 100644 --- a/stock_location_flowable/tests/test_stock_quant.py +++ b/stock_location_flowable/tests/test_stock_quant.py @@ -28,38 +28,13 @@ def test_unique_lot_constraint_at_flowable_location(self): POST: - ValidationError is raised about duplicate lots """ # ARRANGE — first lot via inventory adjustment - lot_1 = self.env["stock.production.lot"].create( - { - "name": "QUANT-LOT-1", - "product_id": self.product_flowable_1.id, - } - ) - - inventory_1 = self.env["stock.inventory"].create( - { - "name": "Add first lot", - } - ) - inventory_1.action_start() - self.env["stock.inventory.line"].create( - { - "inventory_id": inventory_1.id, - "product_id": self.product_flowable_1.id, - "product_uom_id": self.product_flowable_1.uom_id.id, - "location_id": self.location_flowable_1.id, - "prod_lot_id": lot_1.id, - "product_qty": 100, - } + lot_1 = self._create_lot(self.product_flowable_1, "QUANT-LOT-1") + self._create_inventory_adjustment( + self.location_flowable_1, self.product_flowable_1, lot_1, 100 ) - inventory_1.action_validate() # ACT — second lot via inventory adjustment - lot_2 = self.env["stock.production.lot"].create( - { - "name": "QUANT-LOT-2", - "product_id": self.product_flowable_1.id, - } - ) + lot_2 = self._create_lot(self.product_flowable_1, "QUANT-LOT-2") inventory_2 = self.env["stock.inventory"].create( { diff --git a/stock_location_flowable/tests/test_stock_return_picking.py b/stock_location_flowable/tests/test_stock_return_picking.py index 036c3b4c4..f00bfd8a6 100644 --- a/stock_location_flowable/tests/test_stock_return_picking.py +++ b/stock_location_flowable/tests/test_stock_return_picking.py @@ -27,46 +27,11 @@ def test_return_from_flowable_location_raises_error(self): # ARRANGE self.picking_type_mrp_operation_1.flowable_operation = True - lot = self.env["stock.production.lot"].create( - { - "name": "TEST-RETURN-LOT", - "product_id": self.product_flowable_1.id, - } + lot = self._create_lot(self.product_flowable_1, "TEST-RETURN-LOT") + picking = self._seed_flowable_location( + self.location_flowable_1, self.product_flowable_1, lot, 50 ) - picking = self.env["stock.picking"].create( - { - "picking_type_id": self.picking_type_incoming_1.id, - "location_id": self.env.ref("stock.stock_location_suppliers").id, - "location_dest_id": self.location_flowable_1.id, - } - ) - self.env["stock.move.line"].create( - { - "picking_id": picking.id, - "product_id": self.product_flowable_1.id, - "product_uom_id": self.product_flowable_1.uom_id.id, - "lot_id": lot.id, - "qty_done": 50, - "location_id": self.env.ref("stock.stock_location_suppliers").id, - "location_dest_id": self.location_flowable_1.id, - "company_id": self.env.company.id, - } - ) - picking.button_validate() - - # Complete the mixing MO so we have a done picking - production = self.env["mrp.production"].search( - [ - ("picking_type_id", "=", self.picking_type_mrp_operation_1.id), - ("location_dest_id", "=", self.location_flowable_1.id), - ], - order="id desc", - limit=1, - ) - if production: - production.button_mark_done() - # ACT return_wizard = ( self.env["stock.return.picking"] @@ -77,7 +42,7 @@ def test_return_from_flowable_location_raises_error(self): .create( { "picking_id": picking.id, - "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_id": self.supplier_location.id, } ) ) @@ -104,17 +69,12 @@ def test_return_from_non_flowable_location_succeeds(self): POST: - Return picking is created successfully """ # ARRANGE - lot = self.env["stock.production.lot"].create( - { - "name": "TEST-RETURN-NOFLO", - "product_id": self.product_flowable_1.id, - } - ) + lot = self._create_lot(self.product_flowable_1, "TEST-RETURN-NOFLO") picking = self.env["stock.picking"].create( { "picking_type_id": self.picking_type_incoming_1.id, - "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_id": self.supplier_location.id, "location_dest_id": self.location_1.id, } ) @@ -125,7 +85,7 @@ def test_return_from_non_flowable_location_succeeds(self): "product_uom_qty": 50, "product_uom": self.product_flowable_1.uom_id.id, "picking_id": picking.id, - "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_id": self.supplier_location.id, "location_dest_id": self.location_1.id, } ) @@ -149,7 +109,7 @@ def test_return_from_non_flowable_location_succeeds(self): .create( { "picking_id": picking.id, - "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_id": self.supplier_location.id, } ) ) From a780841062f83bb17f4fa18bfa0f3538aa1de1a5 Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Sat, 28 Feb 2026 02:37:26 +0100 Subject: [PATCH 23/31] [IMP] stock_location_flowable: increase test coverage to 99% Add 4 natural user-workflow tests covering previously untested code paths in the flowable blocking lifecycle: - MO-based reservation conflict detection (mrp_production lines 93-99) - Capacity reduction below occupied amount (stock_location line 123) - Flowable conversion with incompatible UoM stock (stock_location lines 195-198) - Mixing reception with rounding residual quant (stock_picking line 242) --- .../tests/test_mrp_production.py | 130 ++++++++++++++++++ .../tests/test_stock_location.py | 72 ++++++++++ .../tests/test_stock_picking.py | 53 +++++++ 3 files changed, 255 insertions(+) diff --git a/stock_location_flowable/tests/test_mrp_production.py b/stock_location_flowable/tests/test_mrp_production.py index 8113032f3..70cc63ba6 100644 --- a/stock_location_flowable/tests/test_mrp_production.py +++ b/stock_location_flowable/tests/test_mrp_production.py @@ -671,6 +671,136 @@ def test_production_without_bom_allowed_for_flowable(self): self.assertTrue(production) +class TestFlowableReservationConflictFromProduction(TestCommon): + @classmethod + def setUpClass(cls): + super(TestFlowableReservationConflictFromProduction, cls).setUpClass() + + cls.picking_type_mrp = cls.env["stock.picking.type"].create( + { + "name": "TestFlowableProd", + "sequence_code": "SEQ-FLPROD", + "code": "mrp_operation", + "flowable_operation": True, + } + ) + + cls.location_fl = cls.env["stock.location"].create( + { + "name": "FLRes", + "usage": "internal", + "location_id": cls.env.ref("stock.stock_location_locations_partner").id, + "flowable_storage": True, + "flowable_capacity": 5000, + "flowable_uom_id": cls.env.ref("uom.product_uom_litre").id, + "flowable_allowed_product_ids": [(4, cls.product_flowable_1.id)], + } + ) + + def test_reservation_conflict_from_production_move(self): + """ + Test that receiving stock at a flowable location is rejected when + a regular (non-flowable) MO has reserved stock from that location. + + This covers the `elif move.raw_material_production_id` branch in + _check_flowable_reservation (moves that belong to another MO, + not to a picking). + + PRE: - Flowable location FLRes with 500 L of lot A (seeded) + - A finished product with a BoM consuming 100 L of the + flowable product + - A regular MO confirmed + assigned (reserves 100 L) + ACT: - Receive 200 L of lot B at FLRes + POST: - UserError is raised mentioning the regular MO + """ + # ARRANGE — seed the flowable location + lot_a = self._create_lot(self.product_flowable_1, "RES-LOT-A") + self._seed_flowable_location( + self.location_fl, + self.product_flowable_1, + lot_a, + 500, + mrp_picking_type=self.picking_type_mrp, + ) + self.assertFalse(self.location_fl.flowable_blocked) + + # Create a finished product with a BoM + finished_product = self.env["product.product"].create( + { + "name": "Finished Product", + "type": "product", + "uom_id": self.env.ref("uom.product_uom_unit").id, + "uom_po_id": self.env.ref("uom.product_uom_unit").id, + } + ) + bom = self.env["mrp.bom"].create( + { + "product_tmpl_id": finished_product.product_tmpl_id.id, + "product_qty": 1, + "bom_line_ids": [ + ( + 0, + 0, + { + "product_id": self.product_flowable_1.id, + "product_qty": 100, + }, + ) + ], + } + ) + + # Create a regular MO sourcing directly from the flowable location + regular_picking_type = self.env["stock.picking.type"].search( + [ + ("warehouse_id", "=", self.picking_type_incoming_1.warehouse_id.id), + ("code", "=", "mrp_operation"), + ("flowable_operation", "=", False), + ], + limit=1, + ) + if not regular_picking_type: + regular_picking_type = self.env["stock.picking.type"].create( + { + "name": "RegularProduction", + "sequence_code": "SEQ-REGPROD", + "code": "mrp_operation", + } + ) + regular_mo = self.env["mrp.production"].create( + { + "product_id": finished_product.id, + "bom_id": bom.id, + "product_qty": 1, + "product_uom_id": finished_product.uom_id.id, + "picking_type_id": regular_picking_type.id, + "location_src_id": self.location_fl.id, + "location_dest_id": self.location_fl.location_id.id, + } + ) + # Populate raw and finished moves from the BoM + self.env["stock.move"].create(regular_mo._get_moves_raw_values()) + self.env["stock.move"].create(regular_mo._get_moves_finished_values()) + regular_mo.action_confirm() + regular_mo.action_assign() + + # Verify the regular MO reserved stock at the flowable location + raw_move = regular_mo.move_raw_ids + self.assertGreater( + raw_move.reserved_availability, + 0, + "Regular MO should have reserved stock from the flowable location", + ) + + # ACT — receive new stock at the flowable location + lot_b = self._create_lot(self.product_flowable_1, "RES-LOT-B") + with self.assertRaises(UserError) as error: + self._receive_stock(self.location_fl, self.product_flowable_1, lot_b, 200) + + # ASSERT — error should mention the regular MO + self.assertIn(regular_mo.name, str(error.exception)) + + class TestFlowableBlockingWithReservations(TestCommon): @classmethod def setUpClass(cls): diff --git a/stock_location_flowable/tests/test_stock_location.py b/stock_location_flowable/tests/test_stock_location.py index 915314708..034fbd0a6 100644 --- a/stock_location_flowable/tests/test_stock_location.py +++ b/stock_location_flowable/tests/test_stock_location.py @@ -650,6 +650,78 @@ def test_convert_location_with_unmixed_products(self): msg_error = self.get_error_message_regex(msg_error) self.assertRegex(error.exception.args[0], msg_error) + def test_reduce_capacity_below_occupied_via_config(self): + """ + Test that reducing a flowable location's capacity below the occupied + amount via configuration raises a specific error about capacity + vs occupied. + + PRE: - A flowable location with stock (occupied ~100 L) + ACT: - Try to reduce capacity to 50 (below occupied) + POST: - ValidationError "Capacity must be greater than capacity + occupied" is raised + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + product = self.location_flowable_1.flowable_allowed_product_ids[0] + + lot = self._create_lot(product, "TEST-CAPCFG-LOT") + self._seed_flowable_location(self.location_flowable_1, product, lot, 100) + + # Verify occupied amount is set + self.location_flowable_1.invalidate_cache() + self.assertGreater(self.location_flowable_1.flowable_capacity_occupied, 0) + + # ACT & ASSERT + with self.assertRaises(ValidationError) as error: + self.location_flowable_1.write({"flowable_capacity": 50}) + + msg_error = "Capacity must be greater than capacity occupied" + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_convert_location_with_incompatible_uom_stock(self): + """ + Test that enabling flowable_storage on a location that has stock + with a UoM different from the specified flowable_uom_id is blocked. + + PRE: - A non-flowable location with stock of a product using + Litres as UoM + ACT: - Try to enable flowable_storage with flowable_uom_id set + to Units (different from the product's Litres) + POST: - UserError about products with different units of measure + """ + # ARRANGE — stock the non-flowable location with a Litres product + product_litre = self.env["product.product"].create( + { + "name": "Test Product Litres UoM", + "type": "product", + "uom_id": self.uom_litre.id, + "uom_po_id": self.uom_litre.id, + "tracking": "lot", + } + ) + lot = self._create_lot(product_litre, "INCOMPAT-UOM-LOT") + self._create_inventory_adjustment(self.location_1, product_litre, lot, 50) + + # ACT & ASSERT — try to enable flowable with Units UoM + with self.assertRaises(UserError) as error: + self.location_1.write( + { + "flowable_storage": True, + "flowable_capacity": 1000, + "flowable_uom_id": self.uom_unit.id, + "flowable_allowed_product_ids": [(4, product_litre.id)], + } + ) + + msg_error = ( + "You cannot convert this location into a flowable location" + " because there are products with different units of measure." + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + def test_capacity_occupied_zero_for_non_flowable_location(self): """ Test that a non-flowable location always has diff --git a/stock_location_flowable/tests/test_stock_picking.py b/stock_location_flowable/tests/test_stock_picking.py index 5b003c11b..242a238f0 100644 --- a/stock_location_flowable/tests/test_stock_picking.py +++ b/stock_location_flowable/tests/test_stock_picking.py @@ -751,3 +751,56 @@ def test_non_flowable_location_not_affected(self): # ASSERT self.assertFalse(self.location_1.flowable_storage) self.assertFalse(picking.flowable_production_ids) + + def test_mixing_reception_with_rounding_residual_rejected(self): + """ + Test that receiving stock at a flowable location is rejected when + there are 3 positive quants instead of the expected 2 for a mixing + scenario. + + This reproduces the rounding residual scenario (like the MO 00310 + incident): a tiny residual from a previous UoM rounding issue + creates an extra quant, making the location state inconsistent. + + PRE: - Flowable location with lot A (100 L) from completed MO + - Inventory adjustment adds 0.01 L of lot B (rounding + residual) + - Location now has 2 quants: A (100 L) + B (0.01 L) + ACT: - Receive lot C (50 L) at the flowable location + POST: - After _action_done, there are 3 positive quants + - UserError "expected 2 positive quants but found 3" + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + # Seed the flowable location with lot A + lot_a = self._create_lot(self.product_flowable_1, "RESIDUAL-LOT-A") + self._seed_flowable_location( + self.location_flowable_1, self.product_flowable_1, lot_a, 100 + ) + + # Add a rounding residual via inventory adjustment (lot B, 0.01 L) + lot_b = self._create_lot(self.product_flowable_1, "RESIDUAL-LOT-B") + self._create_inventory_adjustment( + self.location_flowable_1, self.product_flowable_1, lot_b, 0.01 + ) + + # Verify we have 2 positive quants now + quants = self._get_location_quants( + self.location_flowable_1, self.product_flowable_1 + ) + positive_quants = quants.filtered(lambda q: q.quantity > 0) + self.assertEqual( + len(positive_quants), 2, "Should have 2 quants (lot A + residual lot B)" + ) + + # ACT — receive lot C, which creates a 3rd quant + lot_c = self._create_lot(self.product_flowable_1, "RESIDUAL-LOT-C") + with self.assertRaises(UserError) as error: + self._receive_stock( + self.location_flowable_1, self.product_flowable_1, lot_c, 50 + ) + + # ASSERT + msg_error = "expected 2 positive quants but found 3" + self.assertIn(msg_error, str(error.exception)) From 94ae4a51dfaf2f8116f289469cab53a49a032fec Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Sun, 1 Mar 2026 18:15:19 +0100 Subject: [PATCH 24/31] [REF] stock_location_flowable: use domain-consistent names in tests Rename all test data to use liquid gas domain examples instead of generic or Spanish names, making tests self-documenting and consistent with the real-world use case. Products: Liquid O2, Liquid N2, Liquid He, etc. Locations: O2 Tank 1..6, Warehouse Shelf UoM categories: Liquid O2 (kg/L density conversion) Non-gas products: Steel Bolts (for "not allowed" scenarios) --- stock_location_flowable/tests/test_common.py | 10 +- .../tests/test_mrp_production.py | 124 ++++++++++-------- .../tests/test_stock_location.py | 14 +- .../tests/test_stock_picking.py | 44 +++---- 4 files changed, 101 insertions(+), 91 deletions(-) diff --git a/stock_location_flowable/tests/test_common.py b/stock_location_flowable/tests/test_common.py index c0867efeb..d1a6d437f 100644 --- a/stock_location_flowable/tests/test_common.py +++ b/stock_location_flowable/tests/test_common.py @@ -62,7 +62,7 @@ def setUpClass(cls): cls.product_flowable_1 = cls.env["product.product"].create( { - "name": "ProductFlowable1", + "name": "Liquid O2", "type": "product", "uom_id": cls.env.ref("uom.product_uom_litre").id, "uom_po_id": cls.env.ref("uom.product_uom_litre").id, @@ -72,7 +72,7 @@ def setUpClass(cls): cls.product_flowable_2 = cls.env["product.product"].create( { - "name": "ProductFlowable2", + "name": "Liquid N2", "type": "product", "uom_id": cls.env.ref("uom.product_uom_litre").id, "uom_po_id": cls.env.ref("uom.product_uom_litre").id, @@ -82,7 +82,7 @@ def setUpClass(cls): cls.location_1 = cls.env["stock.location"].create( { - "name": "Location1", + "name": "Warehouse Shelf", "usage": "internal", "location_id": cls.env.ref("stock.stock_location_locations_partner").id, } @@ -90,7 +90,7 @@ def setUpClass(cls): cls.location_flowable_1 = cls.env["stock.location"].create( { - "name": "LocationFlowable1", + "name": "O2 Tank 1", "usage": "internal", "location_id": cls.env.ref("stock.stock_location_locations_partner").id, "flowable_storage": True, @@ -113,7 +113,7 @@ def setUpClass(cls): cls.location_flowable_2 = cls.env["stock.location"].create( { - "name": "LocationFlowable2", + "name": "O2 Tank 2", "usage": "internal", "location_id": cls.env.ref("stock.stock_location_locations_partner").id, "flowable_storage": True, diff --git a/stock_location_flowable/tests/test_mrp_production.py b/stock_location_flowable/tests/test_mrp_production.py index 70cc63ba6..2a28b4538 100644 --- a/stock_location_flowable/tests/test_mrp_production.py +++ b/stock_location_flowable/tests/test_mrp_production.py @@ -301,10 +301,10 @@ def test_flowable_mixing_with_custom_uom_rounding(self): dp.digits = 5 # Custom UoM with fine rounding (like customer's Litro O2) - uom_category_o2 = self.env["uom.category"].create({"name": "Test Volumen O2"}) + uom_category_o2 = self.env["uom.category"].create({"name": "Liquid O2"}) uom_litro_o2 = self.env["uom.uom"].create( { - "name": "Test Litro O2", + "name": "Litre O2", "category_id": uom_category_o2.id, "uom_type": "reference", "rounding": 0.001, @@ -314,7 +314,7 @@ def test_flowable_mixing_with_custom_uom_rounding(self): product_o2 = self.env["product.product"].create( { - "name": "Test Oxigeno Liquido", + "name": "Liquid O2", "type": "product", "uom_id": uom_litro_o2.id, "uom_po_id": uom_litro_o2.id, @@ -324,7 +324,7 @@ def test_flowable_mixing_with_custom_uom_rounding(self): location_cistern = self.env["stock.location"].create( { - "name": "Test Cisterna O2", + "name": "O2 Tank 6", "usage": "internal", "location_id": self.env.ref( "stock.stock_location_locations_partner" @@ -432,12 +432,10 @@ def test_flowable_mixing_multiple_cycles_fine_rounding(self): self.picking_type_mrp_operation_1.flowable_operation = True - uom_category_o2 = self.env["uom.category"].create( - {"name": "Test Volumen O2 Multi"} - ) + uom_category_o2 = self.env["uom.category"].create({"name": "Liquid O2"}) uom_litro_o2 = self.env["uom.uom"].create( { - "name": "Test Litro O2 Multi", + "name": "Litre O2", "category_id": uom_category_o2.id, "uom_type": "reference", "rounding": 0.001, @@ -447,7 +445,7 @@ def test_flowable_mixing_multiple_cycles_fine_rounding(self): product_o2 = self.env["product.product"].create( { - "name": "Test Oxigeno Multi", + "name": "Liquid O2", "type": "product", "uom_id": uom_litro_o2.id, "uom_po_id": uom_litro_o2.id, @@ -457,7 +455,7 @@ def test_flowable_mixing_multiple_cycles_fine_rounding(self): location_cistern = self.env["stock.location"].create( { - "name": "Test Cisterna O2 Multi", + "name": "O2 Tank 6", "usage": "internal", "location_id": self.env.ref( "stock.stock_location_locations_partner" @@ -685,9 +683,9 @@ def setUpClass(cls): } ) - cls.location_fl = cls.env["stock.location"].create( + cls.location_flowable_3 = cls.env["stock.location"].create( { - "name": "FLRes", + "name": "O2 Tank 3", "usage": "internal", "location_id": cls.env.ref("stock.stock_location_locations_partner").id, "flowable_storage": True, @@ -706,23 +704,23 @@ def test_reservation_conflict_from_production_move(self): _check_flowable_reservation (moves that belong to another MO, not to a picking). - PRE: - Flowable location FLRes with 500 L of lot A (seeded) + PRE: - Flowable location 'O2 Tank 3' with 500 L of lot A (seeded) - A finished product with a BoM consuming 100 L of the flowable product - A regular MO confirmed + assigned (reserves 100 L) - ACT: - Receive 200 L of lot B at FLRes + ACT: - Receive 200 L of lot B at 'O2 Tank 3' POST: - UserError is raised mentioning the regular MO """ # ARRANGE — seed the flowable location lot_a = self._create_lot(self.product_flowable_1, "RES-LOT-A") self._seed_flowable_location( - self.location_fl, + self.location_flowable_3, self.product_flowable_1, lot_a, 500, mrp_picking_type=self.picking_type_mrp, ) - self.assertFalse(self.location_fl.flowable_blocked) + self.assertFalse(self.location_flowable_3.flowable_blocked) # Create a finished product with a BoM finished_product = self.env["product.product"].create( @@ -774,8 +772,8 @@ def test_reservation_conflict_from_production_move(self): "product_qty": 1, "product_uom_id": finished_product.uom_id.id, "picking_type_id": regular_picking_type.id, - "location_src_id": self.location_fl.id, - "location_dest_id": self.location_fl.location_id.id, + "location_src_id": self.location_flowable_3.id, + "location_dest_id": self.location_flowable_3.location_id.id, } ) # Populate raw and finished moves from the BoM @@ -795,7 +793,9 @@ def test_reservation_conflict_from_production_move(self): # ACT — receive new stock at the flowable location lot_b = self._create_lot(self.product_flowable_1, "RES-LOT-B") with self.assertRaises(UserError) as error: - self._receive_stock(self.location_fl, self.product_flowable_1, lot_b, 200) + self._receive_stock( + self.location_flowable_3, self.product_flowable_1, lot_b, 200 + ) # ASSERT — error should mention the regular MO self.assertIn(regular_mo.name, str(error.exception)) @@ -815,9 +815,9 @@ def setUpClass(cls): } ) - cls.location_fl1 = cls.env["stock.location"].create( + cls.location_flowable_4 = cls.env["stock.location"].create( { - "name": "FL1", + "name": "O2 Tank 4", "usage": "internal", "location_id": cls.env.ref("stock.stock_location_locations_partner").id, "flowable_storage": True, @@ -872,40 +872,42 @@ def test_flowable_blocking_with_pending_reservations_and_reception(self): confirmed (not reserved). The mixing MO cannot fully reserve because Sales 1 and 3 hold reservations on the stock. - PRE: - Flowable location FL1 (capacity 15000 L), initially empty + PRE: - Flowable location 'O2 Tank 4' (capacity 15000 L), initially empty - Receive 7000 L of lot X1 via reception + MO (seed) - Sale 1: 100 L of X1 confirmed + reserved (assigned) - Sale 2: 200 L of X1 confirmed only (not reserved) - Inventory adjustment: +1000 L on lot X1 - Sale 3: 600 L of X1 confirmed + reserved (assigned) - ACT: - Receive 5000 L of lot P1 at FL1 + ACT: - Receive 5000 L of lot P1 at 'O2 Tank 4' POST: - UserError is raised mentioning Sales 1 and 3 (the only ones with active reservations) """ # ARRANGE lot_x1 = self._create_lot(self.product_flowable_1, "X1") self._seed_flowable_location( - self.location_fl1, + self.location_flowable_4, self.product_flowable_1, lot_x1, 7000, mrp_picking_type=self.picking_type_mrp, ) self.assertEqual( - self._get_positive_quantity(self.location_fl1, self.product_flowable_1), + self._get_positive_quantity( + self.location_flowable_4, self.product_flowable_1 + ), 7000, ) - self.assertFalse(self.location_fl1.flowable_blocked) + self.assertFalse(self.location_flowable_4.flowable_blocked) # Sale 1: 100 L of X1, confirmed + reserved (assigned) sale_picking_1 = self._create_sale_picking( - self.location_fl1, self.product_flowable_1, "Sale 1 - X1 100L", 100 + self.location_flowable_4, self.product_flowable_1, "Sale 1 - X1 100L", 100 ) self.assertEqual(sale_picking_1.state, "assigned") # Sale 2: 200 L of X1, confirmed only (not reserved) sale_picking_2 = self._create_sale_picking( - self.location_fl1, + self.location_flowable_4, self.product_flowable_1, "Sale 2 - X1 200L", 200, @@ -917,49 +919,51 @@ def test_flowable_blocking_with_pending_reservations_and_reception(self): inventory = self.env["stock.inventory"].create( { "name": "Adjust +1000L on X1", - "location_ids": [(4, self.location_fl1.id)], + "location_ids": [(4, self.location_flowable_4.id)], "product_ids": [(4, self.product_flowable_1.id)], } ) inventory.action_start() inv_line = inventory.line_ids.filtered( - lambda l: l.location_id == self.location_fl1 + lambda l: l.location_id == self.location_flowable_4 ) inv_line[0].product_qty = inv_line[0].product_qty + 1000 inventory.action_validate() self.assertEqual( - self._get_positive_quantity(self.location_fl1, self.product_flowable_1), + self._get_positive_quantity( + self.location_flowable_4, self.product_flowable_1 + ), 8000, ) # Sale 3: 600 L of X1, confirmed + reserved (assigned) sale_picking_3 = self._create_sale_picking( - self.location_fl1, self.product_flowable_1, "Sale 3 - X1 600L", 600 + self.location_flowable_4, self.product_flowable_1, "Sale 3 - X1 600L", 600 ) self.assertEqual(sale_picking_3.state, "assigned") - self.assertFalse(self.location_fl1.flowable_blocked) + self.assertFalse(self.location_flowable_4.flowable_blocked) # ACT lot_p1 = self._create_lot(self.product_flowable_1, "P1") with self.assertRaises(UserError) as error: self._receive_stock( - self.location_fl1, self.product_flowable_1, lot_p1, 5000 + self.location_flowable_4, self.product_flowable_1, lot_p1, 5000 ) # ASSERT # Only Sales 1 and 3 appear in the error (the ones with active # reservations). Sale 2 is only confirmed, not reserved. expected_msg = ( - "Cannot merge at flowable location 'FL1'" + "Cannot merge at flowable location 'O2 Tank 4'" " because there are reserved quantities." " After the merge, the current lot(s) will" " have 0 stock and these reservations will" " become invalid.\n\n" "The following operations must be unreserved" " or completed first:\n\n" - " - ProductFlowable1: 100.0 L (lot X1)" + " - Liquid O2: 100.0 L (lot X1)" " - %s (Delivery1)\n" - " - ProductFlowable1: 600.0 L (lot X1)" + " - Liquid O2: 600.0 L (lot X1)" " - %s (Delivery1)" ) % (sale_picking_1.name, sale_picking_3.name) self.assertEqual(str(error.exception), expected_msg) @@ -973,34 +977,36 @@ def test_flowable_merge_succeeds_with_unreserved_operations(self): unreserved sales stay confirmed. The mixing MO fully reserves all stock and succeeds. - PRE: - Flowable location FL1 (capacity 15000 L), initially empty + PRE: - Flowable location 'O2 Tank 4' (capacity 15000 L), initially empty - Receive 7000 L of lot X1 via reception + MO (seed) - Sale 1: 100 L of X1 reserved then unreserved - Sale 2: 200 L of X1 reserved then unreserved - Inventory adjustment: +1000 L on lot X1 - Sale 3: 600 L of X1 reserved then unreserved - ACT: - Receive 5000 L of lot P1 at FL1 + ACT: - Receive 5000 L of lot P1 at 'O2 Tank 4' POST: - No error is raised - - A mixing MO is created and FL1 is blocked + - A mixing MO is created and 'O2 Tank 4' is blocked """ # ARRANGE lot_x1 = self._create_lot(self.product_flowable_1, "X1") self._seed_flowable_location( - self.location_fl1, + self.location_flowable_4, self.product_flowable_1, lot_x1, 7000, mrp_picking_type=self.picking_type_mrp, ) self.assertEqual( - self._get_positive_quantity(self.location_fl1, self.product_flowable_1), + self._get_positive_quantity( + self.location_flowable_4, self.product_flowable_1 + ), 7000, ) - self.assertFalse(self.location_fl1.flowable_blocked) + self.assertFalse(self.location_flowable_4.flowable_blocked) # Sale 1: 100 L of X1, reserved then unreserved sale_picking_1 = self._create_sale_picking( - self.location_fl1, + self.location_flowable_4, self.product_flowable_1, "Sale 1 - X1 100L", 100, @@ -1010,7 +1016,7 @@ def test_flowable_merge_succeeds_with_unreserved_operations(self): # Sale 2: 200 L of X1, reserved then unreserved sale_picking_2 = self._create_sale_picking( - self.location_fl1, + self.location_flowable_4, self.product_flowable_1, "Sale 2 - X1 200L", 200, @@ -1022,24 +1028,26 @@ def test_flowable_merge_succeeds_with_unreserved_operations(self): inventory = self.env["stock.inventory"].create( { "name": "Adjust +1000L on X1", - "location_ids": [(4, self.location_fl1.id)], + "location_ids": [(4, self.location_flowable_4.id)], "product_ids": [(4, self.product_flowable_1.id)], } ) inventory.action_start() inv_line = inventory.line_ids.filtered( - lambda l: l.location_id == self.location_fl1 + lambda l: l.location_id == self.location_flowable_4 ) inv_line[0].product_qty = inv_line[0].product_qty + 1000 inventory.action_validate() self.assertEqual( - self._get_positive_quantity(self.location_fl1, self.product_flowable_1), + self._get_positive_quantity( + self.location_flowable_4, self.product_flowable_1 + ), 8000, ) # Sale 3: 600 L of X1, reserved then unreserved sale_picking_3 = self._create_sale_picking( - self.location_fl1, + self.location_flowable_4, self.product_flowable_1, "Sale 3 - X1 600L", 600, @@ -1047,21 +1055,25 @@ def test_flowable_merge_succeeds_with_unreserved_operations(self): ) self.assertEqual(sale_picking_3.state, "confirmed") - # Verify no reserved quantities remain at FL1 - quants = self._get_location_quants(self.location_fl1, self.product_flowable_1) + # Verify no reserved quantities remain at 'O2 Tank 4' + quants = self._get_location_quants( + self.location_flowable_4, self.product_flowable_1 + ) self.assertFalse(any(q.reserved_quantity > 0 for q in quants)) - self.assertFalse(self.location_fl1.flowable_blocked) + self.assertFalse(self.location_flowable_4.flowable_blocked) # ACT lot_p1 = self._create_lot(self.product_flowable_1, "P1") - self._receive_stock(self.location_fl1, self.product_flowable_1, lot_p1, 5000) + self._receive_stock( + self.location_flowable_4, self.product_flowable_1, lot_p1, 5000 + ) # ASSERT production = self._find_flowable_production( - self.location_fl1, picking_type=self.picking_type_mrp + self.location_flowable_4, picking_type=self.picking_type_mrp ) self.assertTrue(production, "A mixing MO should have been created") self.assertTrue( - self.location_fl1.flowable_blocked, - "FL1 should be blocked after reception triggers a mixing MO", + self.location_flowable_4.flowable_blocked, + "'O2 Tank 4' should be blocked after reception triggers a mixing MO", ) diff --git a/stock_location_flowable/tests/test_stock_location.py b/stock_location_flowable/tests/test_stock_location.py index 034fbd0a6..beeac59a5 100644 --- a/stock_location_flowable/tests/test_stock_location.py +++ b/stock_location_flowable/tests/test_stock_location.py @@ -19,7 +19,7 @@ def setUpClass(cls): cls.uom_unit = cls.env.ref("uom.product_uom_unit") cls.product_flowable_1 = cls.env["product.product"].create( { - "name": "Test CO2 1", + "name": "Liquid CO2", "type": "product", "uom_id": cls.uom_litre.id, "uom_po_id": cls.uom_litre.id, @@ -27,13 +27,13 @@ def setUpClass(cls): ) cls.warehouse_bcn = cls.env["stock.warehouse"].create( { - "name": "Test Barcelona", + "name": "Warehouse Barcelona", "code": "BCN", } ) cls.location_flowable_bcn_1 = cls.env["stock.location"].create( { - "name": "Test Flowable bcn 1", + "name": "O2 Tank 5", "location_id": cls.warehouse_bcn.lot_stock_id.id, } ) @@ -205,7 +205,7 @@ def test_adding_product_with_incompatible_uom(self): # ARRANGE product_flowable_2 = self.env["product.product"].create( { - "name": "Test CO2 2", + "name": "CO2 Cylinder", "type": "product", "uom_id": self.uom_unit.id, "uom_po_id": self.uom_unit.id, @@ -250,7 +250,7 @@ def test_changing_to_incompatible_uom_id(self): # ARRANGE product_flowable_2 = self.env["product.product"].create( { - "name": "Test CO2 2", + "name": "CO2 Cylinder", "type": "product", "uom_id": self.uom_unit.id, "uom_po_id": self.uom_unit.id, @@ -591,7 +591,7 @@ def test_change_uom_with_existing_stock(self): # for the allowed products constraint, isolating _check_flowable_uom_id product_unit = self.env["product.product"].create( { - "name": "Test Product Units", + "name": "Ar Cylinder", "type": "product", "uom_id": self.uom_unit.id, "uom_po_id": self.uom_unit.id, @@ -694,7 +694,7 @@ def test_convert_location_with_incompatible_uom_stock(self): # ARRANGE — stock the non-flowable location with a Litres product product_litre = self.env["product.product"].create( { - "name": "Test Product Litres UoM", + "name": "Liquid Ar", "type": "product", "uom_id": self.uom_litre.id, "uom_po_id": self.uom_litre.id, diff --git a/stock_location_flowable/tests/test_stock_picking.py b/stock_location_flowable/tests/test_stock_picking.py index 242a238f0..220c49527 100644 --- a/stock_location_flowable/tests/test_stock_picking.py +++ b/stock_location_flowable/tests/test_stock_picking.py @@ -133,9 +133,9 @@ def test_only_allowed_product_in_incoming_picking(self): # ARRANGE self.picking_type_mrp_operation_1.flowable_operation = True - product_zanahoria = self.env["product.product"].create( + product_bolts = self.env["product.product"].create( { - "name": "Zanahoria", + "name": "Steel Bolts", "type": "product", "uom_id": self.env.ref("uom.product_uom_unit").id, "uom_po_id": self.env.ref("uom.product_uom_unit").id, @@ -145,10 +145,10 @@ def test_only_allowed_product_in_incoming_picking(self): moves1 = self.env["stock.move"].create( { - "name": product_zanahoria.name, - "product_id": product_zanahoria.id, + "name": product_bolts.name, + "product_id": product_bolts.id, "product_uom_qty": 5, - "product_uom": product_zanahoria.uom_id.id, + "product_uom": product_bolts.uom_id.id, "picking_id": self.incoming_picking.id, "location_id": self.incoming_picking.location_id.id, "location_dest_id": self.incoming_picking.location_dest_id.id, @@ -157,14 +157,14 @@ def test_only_allowed_product_in_incoming_picking(self): self.incoming_picking.action_confirm() - lot_zanahoria = self._create_lot(product_zanahoria, "TEST COD-0001") + lot_bolts = self._create_lot(product_bolts, "TEST COD-0001") self.incoming_picking.move_line_ids = self.env["stock.move.line"].create( { "move_id": moves1.id, - "product_id": product_zanahoria.id, - "product_uom_id": product_zanahoria.uom_id.id, - "lot_id": lot_zanahoria.id, + "product_id": product_bolts.id, + "product_uom_id": product_bolts.uom_id.id, + "lot_id": lot_bolts.id, "qty_done": 5, "location_id": self.incoming_picking.location_id.id, "location_dest_id": self.incoming_picking.location_dest_id.id, @@ -184,9 +184,9 @@ def test_different_uom_allowed_product_in_incoming_picking(self): # ARRANGE self.picking_type_mrp_operation_1.flowable_operation = True - product_zanahoria = self.env["product.product"].create( + product_he = self.env["product.product"].create( { - "name": "Zanahoria", + "name": "Liquid He", "type": "product", "uom_id": self.env.ref("uom.product_uom_litre").id, "uom_po_id": self.env.ref("uom.product_uom_litre").id, @@ -194,11 +194,9 @@ def test_different_uom_allowed_product_in_incoming_picking(self): } ) - self.location_flowable_1.flowable_allowed_product_ids = [ - (4, product_zanahoria.id) - ] + self.location_flowable_1.flowable_allowed_product_ids = [(4, product_he.id)] - product_zanahoria.write( + product_he.write( { "uom_id": self.env.ref("uom.product_uom_unit").id, "uom_po_id": self.env.ref("uom.product_uom_unit").id, @@ -207,10 +205,10 @@ def test_different_uom_allowed_product_in_incoming_picking(self): moves1 = self.env["stock.move"].create( { - "name": product_zanahoria.name, - "product_id": product_zanahoria.id, + "name": product_he.name, + "product_id": product_he.id, "product_uom_qty": 5, - "product_uom": product_zanahoria.uom_id.id, + "product_uom": product_he.uom_id.id, "picking_id": self.incoming_picking.id, "location_id": self.incoming_picking.location_id.id, "location_dest_id": self.incoming_picking.location_dest_id.id, @@ -219,14 +217,14 @@ def test_different_uom_allowed_product_in_incoming_picking(self): self.incoming_picking.action_confirm() - lot_zanahoria = self._create_lot(product_zanahoria, "TEST COD-0001") + lot_he = self._create_lot(product_he, "TEST COD-0001") self.incoming_picking.move_line_ids = self.env["stock.move.line"].create( { "move_id": moves1.id, - "product_id": product_zanahoria.id, - "product_uom_id": product_zanahoria.uom_id.id, - "lot_id": lot_zanahoria.id, + "product_id": product_he.id, + "product_uom_id": product_he.uom_id.id, + "lot_id": lot_he.id, "qty_done": 5, "location_id": self.incoming_picking.location_id.id, "location_dest_id": self.incoming_picking.location_dest_id.id, @@ -501,7 +499,7 @@ def test_non_lot_tracked_product_at_flowable_location(self): product_nolot = self.env["product.product"].create( { - "name": "ProductNoLot", + "name": "Liquid H2", "type": "product", "uom_id": self.env.ref("uom.product_uom_litre").id, "uom_po_id": self.env.ref("uom.product_uom_litre").id, From b413546262966ec41702f089727dc7a792a45a23 Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Sun, 1 Mar 2026 19:02:03 +0100 Subject: [PATCH 25/31] [IMP] stock_location_flowable: test multi-line reception aggregation When a reception has multiple move lines targeting the same flowable location with the same product and lot, they must be aggregated into a single manufacturing order. --- .../tests/test_stock_picking.py | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/stock_location_flowable/tests/test_stock_picking.py b/stock_location_flowable/tests/test_stock_picking.py index 220c49527..1cc3f7397 100644 --- a/stock_location_flowable/tests/test_stock_picking.py +++ b/stock_location_flowable/tests/test_stock_picking.py @@ -802,3 +802,92 @@ def test_mixing_reception_with_rounding_residual_rejected(self): # ASSERT msg_error = "expected 2 positive quants but found 3" self.assertIn(msg_error, str(error.exception)) + + def test_multiple_lines_same_product_aggregated_into_one_mo(self): + """ + Test that multiple move lines with the same product, lot, and + destination are aggregated into a single manufacturing order. + + This reproduces the scenario where a PO has N lines of the same + product: each line creates a separate stock move on the picking, + but when all move lines target the same flowable tank with the same + lot, the flowable code must merge them into one MO. + + PRE: - A flowable location seeded with 100 L of lot A (MO completed) + - An incoming picking with 3 move lines of the same product, + same lot B, same destination (simulating 3 PO lines) + ACT: - Validate the picking + POST: - Only 1 MO is created (not 3) + - The MO has 2 raw move lines: one for lot A (existing stock) + and one for lot B (sum of the 3 reception lines) + - The MO quantity equals existing stock + total received + - The location is blocked by that single MO + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + # Seed the tank with 100 L of lot A and complete the initial MO + lot_a = self._create_lot(self.product_flowable_1, "TEST-AGGR-LOT-A") + self._seed_flowable_location( + self.location_flowable_1, self.product_flowable_1, lot_a, 100 + ) + self.assertFalse(self.location_flowable_1.flowable_blocked) + + # Create a picking with 3 move lines of the same product, lot B, same tank + lot_b = self._create_lot(self.product_flowable_1, "TEST-AGGR-LOT-B") + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_incoming_1.id, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_flowable_1.id, + } + ) + + line_qtys = [10, 20, 30] + for qty in line_qtys: + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot_b.id, + "qty_done": qty, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_flowable_1.id, + "company_id": self.env.company.id, + } + ) + + # ACT + picking.button_validate() + + # ASSERT — only 1 MO created + productions = picking.flowable_production_ids + self.assertEqual( + len(productions), + 1, + "Multiple lines of the same product/lot/dest should produce exactly 1 MO", + ) + + # The MO quantity should equal existing stock + total received + total_received = sum(line_qtys) + self.assertEqual(productions.product_qty, 100 + total_received) + + # The MO raw move should have exactly 2 move lines: + # one for lot A (existing 100 L) and one for lot B (received 60 L) + raw_move_lines = productions.move_raw_ids.move_line_ids + self.assertEqual( + len(raw_move_lines), + 2, + "MO should have 2 raw move lines: existing lot + received lot", + ) + lot_a_line = raw_move_lines.filtered(lambda ml: ml.lot_id == lot_a) + lot_b_line = raw_move_lines.filtered(lambda ml: ml.lot_id == lot_b) + self.assertEqual(len(lot_a_line), 1) + self.assertEqual(len(lot_b_line), 1) + self.assertEqual(lot_a_line.qty_done, 100) + self.assertEqual(lot_b_line.qty_done, total_received) + + # The location should be blocked by that MO + self.assertTrue(self.location_flowable_1.flowable_blocked) + self.assertEqual(self.location_flowable_1.flowable_production_id, productions) From d4cc3778f89b776531e008ab0642c4d27dab022a Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Sun, 1 Mar 2026 19:40:55 +0100 Subject: [PATCH 26/31] [IMP] stock_location_flowable: clarify pre-check error for conflicting flowable lines The old message ("You can only receive one product at location...") was confusing: it mentioned "one product" even when the conflict was between lots, not products, and gave vague instructions. The new message lists the conflicting product/lot combinations and tells the user to create a backorder. Also adds a comment explaining why we keep the pre-check instead of relying on the blocking mechanism (EAFP): the natural constraint error would reference a phantom MO from the same rolled-back transaction. --- stock_location_flowable/i18n/ca.po | 14 +++++----- stock_location_flowable/i18n/es.po | 14 +++++----- .../models/stock_picking.py | 27 ++++++++++++------- .../tests/test_stock_picking.py | 10 ++++--- 4 files changed, 40 insertions(+), 25 deletions(-) diff --git a/stock_location_flowable/i18n/ca.po b/stock_location_flowable/i18n/ca.po index 27007c513..806c84638 100644 --- a/stock_location_flowable/i18n/ca.po +++ b/stock_location_flowable/i18n/ca.po @@ -361,13 +361,15 @@ msgstr "Origen desconegut (moviment %s)" #: code:addons/stock_location_flowable/models/stock_picking.py:0 #, python-format msgid "" -"You can only receive one product at location %s because a manufacturing " -"order must be generated and the location will be blocked. Create a partial " -"delivery for this product %s." +"Cannot receive multiple product/lot combinations (%s) at flowable location " +"'%s' in the same receipt. Each combination generates a separate mixing order " +"and the location is blocked after the first one. Create a backorder to " +"receive them in separate steps." msgstr "" -"Només pots rebre un producte a la ubicació %s perquè s'ha de generar una " -"ordre de producció i la ubicació quedarà bloquejada. Crea un lliurament " -"parcial per a aquest producte %s." +"No es poden rebre múltiples combinacions de producte/lot (%s) a la ubicació " +"de fluids '%s' en el mateix albarà. Cada combinació genera una ordre de " +"mescla separada i la ubicació queda bloquejada després de la primera. Creeu " +"un albarà parcial per rebre'ls en passos separats." #. module: stock_location_flowable #: code:addons/stock_location_flowable/models/mrp_production.py:0 diff --git a/stock_location_flowable/i18n/es.po b/stock_location_flowable/i18n/es.po index d5983f3b0..4bb31d6c7 100644 --- a/stock_location_flowable/i18n/es.po +++ b/stock_location_flowable/i18n/es.po @@ -380,13 +380,15 @@ msgstr "Origen desconocido (movimiento %s)" #: code:addons/stock_location_flowable/models/stock_picking.py:0 #, python-format msgid "" -"You can only receive one product at location %s because a manufacturing " -"order must be generated and the location will be blocked. Create a partial " -"delivery for this product %s." +"Cannot receive multiple product/lot combinations (%s) at flowable location " +"'%s' in the same receipt. Each combination generates a separate mixing order " +"and the location is blocked after the first one. Create a backorder to " +"receive them in separate steps." msgstr "" -"Solo puedes recibir un producto en la ubicación %s porque se debe generar " -"una orden de producción y la ubicación será bloqueada. Crea una entrega " -"parcial para este producto %s." +"No se pueden recibir múltiples combinaciones de producto/lote (%s) en la " +"ubicación de fluidos '%s' en el mismo albarán. Cada combinación genera una " +"orden de mezcla separada y la ubicación queda bloqueada después de la " +"primera. Cree un albarán parcial para recibirlos en pasos separados." #. module: stock_location_flowable #: code:addons/stock_location_flowable/models/mrp_production.py:0 diff --git a/stock_location_flowable/models/stock_picking.py b/stock_location_flowable/models/stock_picking.py index 5867c2e5c..016fa2e53 100644 --- a/stock_location_flowable/models/stock_picking.py +++ b/stock_location_flowable/models/stock_picking.py @@ -144,22 +144,31 @@ def _action_done(self): % mrp_operation_type.display_name ) - # group move lines by product, destination location and lot to check if - # there are multiple products for the same location and to sum the - # quantity to produce + # Group move lines by (product, dest location, lot) and check for + # conflicts. The blocking mechanism would catch this too (the first + # MO blocks the location, the second hits the constraint), but + # that error references a phantom MO created in the same rolled-back + # transaction. Pre-checking here gives an actionable message. lines = {} for line in flowable_lines: key = (line.product_id, line.location_dest_id, line.lot_id) lines[key] = lines.get(key, 0) + line.qty_done - if any(k[1] == line.location_dest_id and k != key for k in lines): + existing = [k for k in lines if k[1] == line.location_dest_id] + if len(existing) > 1: + details = ", ".join( + "%s (%s)" % (k[0].name, k[2].name) if k[2] else k[0].name + for k in existing + ) raise UserError( _( - "You can only receive one product at location %s" - " because a manufacturing order must be generated" - " and the location will be blocked. Create a " - "partial delivery for this product %s." + "Cannot receive multiple product/lot combinations" + " (%s) at flowable location '%s' in the same" + " receipt. Each combination generates a separate" + " mixing order and the location is blocked after" + " the first one. Create a backorder to receive" + " them in separate steps." ) - % (line.location_dest_id.name, line.product_id.name) + % (details, line.location_dest_id.name) ) # create manufacturing orders diff --git a/stock_location_flowable/tests/test_stock_picking.py b/stock_location_flowable/tests/test_stock_picking.py index 1cc3f7397..85f9a3d61 100644 --- a/stock_location_flowable/tests/test_stock_picking.py +++ b/stock_location_flowable/tests/test_stock_picking.py @@ -121,10 +121,12 @@ def test_receiving_one_product_in_flowable_location_incoming_picking(self): # ASSERT msg_error = ( - "You can only receive one product at location %s" - " because a manufacturing order must be generated" - " and the location will be blocked. Create a " - "partial delivery for this product %s." + "Cannot receive multiple product/lot combinations" + " (%s) at flowable location '%s' in the same" + " receipt. Each combination generates a separate" + " mixing order and the location is blocked after" + " the first one. Create a backorder to receive" + " them in separate steps." ) msg_error = self.get_error_message_regex(msg_error) self.assertRegex(error.exception.args[0], msg_error) From df98be4a2f4dc089edbcab2d36c8055f0bff1e0f Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Sun, 1 Mar 2026 21:25:34 +0100 Subject: [PATCH 27/31] [IMP] stock_location_flowable: add defensive post-production quant check After completing a flowable mixing order, verify that all non-producing lots at the location have exactly 0 stock using float_is_zero with UoM rounding. This should not be necessary under normal operation and might be removed in the future, but catches rounding residuals or manual inventory adjustments that could silently corrupt stock. --- stock_location_flowable/i18n/ca.po | 12 ++++++ stock_location_flowable/i18n/es.po | 12 ++++++ .../models/mrp_production.py | 43 +++++++++++++++++++ 3 files changed, 67 insertions(+) diff --git a/stock_location_flowable/i18n/ca.po b/stock_location_flowable/i18n/ca.po index 806c84638..f6540c683 100644 --- a/stock_location_flowable/i18n/ca.po +++ b/stock_location_flowable/i18n/ca.po @@ -62,6 +62,18 @@ msgstr "" "\n" "%s" +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/mrp_production.py:0 +#, python-format +msgid "" +"After completing the mixing order '%s' at flowable location '%s', lot '%s' " +"still has %s %s of stock. Expected 0 after merging all raw materials into " +"lot '%s'." +msgstr "" +"Després de completar l'ordre de barreja '%s' a la ubicació fluida '%s', el " +"lot '%s' encara té %s %s d'estoc. S'esperava 0 després de barrejar totes les " +"matèries primeres al lot '%s'." + #. module: stock_location_flowable #: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_capacity #: model_terms:ir.ui.view,arch_db:stock_location_flowable.stock_location_view_form diff --git a/stock_location_flowable/i18n/es.po b/stock_location_flowable/i18n/es.po index 4bb31d6c7..b2cb64a76 100644 --- a/stock_location_flowable/i18n/es.po +++ b/stock_location_flowable/i18n/es.po @@ -64,6 +64,18 @@ msgstr "" "\n" "%s" +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/mrp_production.py:0 +#, python-format +msgid "" +"After completing the mixing order '%s' at flowable location '%s', lot '%s' " +"still has %s %s of stock. Expected 0 after merging all raw materials into " +"lot '%s'." +msgstr "" +"Después de completar la orden de mezcla '%s' en la ubicación fluida '%s', el " +"lote '%s' todavía tiene %s %s de stock. Se esperaba 0 después de mezclar " +"todas las materias primas en el lote '%s'." + #. module: stock_location_flowable #: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_capacity #: model_terms:ir.ui.view,arch_db:stock_location_flowable.stock_location_view_form diff --git a/stock_location_flowable/models/mrp_production.py b/stock_location_flowable/models/mrp_production.py index 874d76616..597fb5037 100644 --- a/stock_location_flowable/models/mrp_production.py +++ b/stock_location_flowable/models/mrp_production.py @@ -4,6 +4,7 @@ from odoo import _, api, fields, models from odoo.exceptions import UserError, ValidationError +from odoo.tools import float_is_zero class MrpProduction(models.Model): @@ -57,6 +58,48 @@ def write(self, vals): ) return super().write(vals) + def button_mark_done(self): + res = super().button_mark_done() + for rec in self: + if rec.picking_type_id.flowable_operation and rec.state == "done": + rec._check_flowable_post_production_quants() + return res + + def _check_flowable_post_production_quants(self): + """Defensive check — should not be necessary under normal operation + and might be removed in the future. After completing a mixing order, + all raw-material lots at the flowable location must have 0 stock — + only the producing lot should remain. Rounding residuals or manual + inventory adjustments could leave non-zero leftovers that silently + corrupt stock. Fail loudly so the issue is caught immediately.""" + self.ensure_one() + location = self.location_src_id + rounding = self.product_uom_id.rounding + quants = self.env["stock.quant"].search( + [ + ("product_id", "=", self.product_id.id), + ("location_id", "=", location.id), + ("lot_id", "!=", self.lot_producing_id.id), + ("company_id", "=", self.company_id.id), + ] + ) + for quant in quants: + if not float_is_zero(quant.quantity, precision_rounding=rounding): + raise ValidationError( + _( + "After completing the mixing order '%s' at" + " flowable location '%s', lot '%s' still has" + " %s %s of stock. Expected 0 after merging" + " all raw materials into lot '%s'.", + self.name, + location.name, + quant.lot_id.name, + quant.quantity, + self.product_uom_id.name, + self.lot_producing_id.name, + ) + ) + def action_assign(self): res = super().action_assign() for rec in self: From 32c44c94f1737765d6e5d69d538d660566d48a5d Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Sun, 1 Mar 2026 21:51:40 +0100 Subject: [PATCH 28/31] [IMP] stock_location_flowable: add tests for edge cases and mixed workflows Cover additional flowable scenarios that follow natural user workflows: picking with mixed flowable/non-flowable lines, sequential receptions with MO completion between each, pre-check rejection of multiple lots to the same location, blocked location preventing a second reception, different lots to different locations in one receipt, zero-qty lines ignored, top-up with same lot at auto-lot location, return from mixed picking, rounding residual quant detection, and defensive post-production quant check for both create_lots modes. --- .../tests/test_mrp_production.py | 74 ++++ .../tests/test_stock_picking.py | 408 ++++++++++++++++++ .../tests/test_stock_return_picking.py | 108 +++++ 3 files changed, 590 insertions(+) diff --git a/stock_location_flowable/tests/test_mrp_production.py b/stock_location_flowable/tests/test_mrp_production.py index 2a28b4538..5bdb6e9ea 100644 --- a/stock_location_flowable/tests/test_mrp_production.py +++ b/stock_location_flowable/tests/test_mrp_production.py @@ -641,6 +641,80 @@ def test_write_to_close_production_with_picking_raises_error(self): msg_error = self.get_error_message_regex(msg_error) self.assertRegex(error.exception.args[0], msg_error) + def test_post_production_quant_check_create_lots_false(self): + """ + Test that the defensive post-production quant check passes when + create_lots=False (incoming lot becomes the producing lot). + + PRE: - Flowable location (create_lots=False) seeded with lot A + ACT: - Receive lot B, complete the mixing MO + POST: - No error is raised (all non-producing lots have 0 stock) + - Only the producing lot has positive stock + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_a = self._create_lot(self.product_flowable_1, "POST-CHECK-A") + self._seed_flowable_location( + self.location_flowable_1, self.product_flowable_1, lot_a, 100 + ) + + lot_b = self._create_lot(self.product_flowable_1, "POST-CHECK-B") + + # ACT + self._receive_stock( + self.location_flowable_1, self.product_flowable_1, lot_b, 50 + ) + production = self._find_flowable_production(self.location_flowable_1) + # button_mark_done triggers _check_flowable_post_production_quants + production.button_mark_done() + + # ASSERT — producing lot is the incoming lot (create_lots=False) + self.assertEqual(production.lot_producing_id, lot_b) + quants = self._get_location_quants( + self.location_flowable_1, self.product_flowable_1 + ) + positive_quants = quants.filtered(lambda q: q.quantity > 0) + self.assertEqual(len(positive_quants), 1) + self.assertEqual(positive_quants.lot_id, lot_b) + + def test_post_production_quant_check_create_lots_true(self): + """ + Test that the defensive post-production quant check passes when + create_lots=True (auto-generated lot becomes the producing lot). + + PRE: - Flowable location (create_lots=True) seeded with lot A + ACT: - Receive lot B, complete the mixing MO + POST: - No error is raised + - Only the auto-generated producing lot has positive stock + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_a = self._create_lot(self.product_flowable_1, "POST-CHECK-AUTO-A") + self._seed_flowable_location( + self.location_flowable_2, self.product_flowable_1, lot_a, 100 + ) + + lot_b = self._create_lot(self.product_flowable_1, "POST-CHECK-AUTO-B") + + # ACT + self._receive_stock( + self.location_flowable_2, self.product_flowable_1, lot_b, 50 + ) + production = self._find_flowable_production(self.location_flowable_2) + production.button_mark_done() + + # ASSERT — producing lot is auto-generated (different from both A and B) + self.assertNotEqual(production.lot_producing_id, lot_a) + self.assertNotEqual(production.lot_producing_id, lot_b) + quants = self._get_location_quants( + self.location_flowable_2, self.product_flowable_1 + ) + positive_quants = quants.filtered(lambda q: q.quantity > 0) + self.assertEqual(len(positive_quants), 1) + self.assertEqual(positive_quants.lot_id, production.lot_producing_id) + def test_production_without_bom_allowed_for_flowable(self): """ Test that a flowable production can be created without a Bill of diff --git a/stock_location_flowable/tests/test_stock_picking.py b/stock_location_flowable/tests/test_stock_picking.py index 85f9a3d61..39ed0a41c 100644 --- a/stock_location_flowable/tests/test_stock_picking.py +++ b/stock_location_flowable/tests/test_stock_picking.py @@ -131,6 +131,59 @@ def test_receiving_one_product_in_flowable_location_incoming_picking(self): msg_error = self.get_error_message_regex(msg_error) self.assertRegex(error.exception.args[0], msg_error) + def test_same_product_different_lots_same_location_rejected(self): + """ + Test that receiving the same product with different lots at + the same flowable location in one receipt is rejected. + + PRE: - A picking with 2 move lines of the same product but + different lots, both targeting the same flowable location + ACT: - Try to validate the picking + POST: - UserError is raised about multiple product/lot combinations + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_a = self._create_lot(self.product_flowable_1, "TEST-DIFFLOT-A") + lot_b = self._create_lot(self.product_flowable_1, "TEST-DIFFLOT-B") + + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_incoming_1.id, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_flowable_1.id, + } + ) + + for lot, qty in [(lot_a, 10), (lot_b, 20)]: + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot.id, + "qty_done": qty, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_flowable_1.id, + "company_id": self.env.company.id, + } + ) + + # ACT & ASSERT + with self.assertRaises(UserError) as error: + picking.button_validate() + + msg_error = ( + "Cannot receive multiple product/lot combinations" + " (%s) at flowable location '%s' in the same" + " receipt. Each combination generates a separate" + " mixing order and the location is blocked after" + " the first one. Create a backorder to receive" + " them in separate steps." + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + def test_only_allowed_product_in_incoming_picking(self): # ARRANGE self.picking_type_mrp_operation_1.flowable_operation = True @@ -893,3 +946,358 @@ def test_multiple_lines_same_product_aggregated_into_one_mo(self): # The location should be blocked by that MO self.assertTrue(self.location_flowable_1.flowable_blocked) self.assertEqual(self.location_flowable_1.flowable_production_id, productions) + + def test_backorder_cascade_three_lots(self): + """ + Test the backorder cascade: 3 lots to the same flowable location, + processed one at a time via backorders. + + PRE: - 3 sequential receptions each with 1 lot, where each creates + a backorder for the remaining + ACT: - Receive lot 0, validate → MO → complete → unblocked + - Receive lot 1, validate → MO → complete → unblocked + - Receive lot 2, validate → MO → complete → unblocked + POST: - 3 MOs created total, all completed, location unblocked at end + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lots = [ + self._create_lot(self.product_flowable_1, f"CASCADE-LOT-{i}") + for i in range(3) + ] + qtys = [100, 200, 150] + + completed_productions = self.env["mrp.production"] + + for step, (lot, qty) in enumerate(zip(lots, qtys)): + picking = self._create_incoming_picking( + self.location_flowable_1, self.product_flowable_1, lot, qty + ) + picking.button_validate() + + production = self._find_flowable_production(self.location_flowable_1) + self.assertTrue(production, f"MO should be created at step {step}") + production.button_mark_done() + completed_productions |= production + + # ASSERT + self.assertEqual(len(completed_productions), 3) + self.assertTrue(all(p.state == "done" for p in completed_productions)) + self.assertFalse(self.location_flowable_1.flowable_blocked) + + def test_backorder_remaining_lines_still_rejected(self): + """ + Test that a backorder with 2 remaining flowable lines to the same + location is rejected by the pre-check. + + PRE: - A picking with 2 move lines of different lots, same product, + same flowable location + - First line validated (creates backorder with 1 remaining) + and MO completed + ACT: - Add a second line to the backorder and try to validate both + POST: - UserError about multiple product/lot combinations + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_0 = self._create_lot(self.product_flowable_1, "BKORD-LOT-0") + lot_1 = self._create_lot(self.product_flowable_1, "BKORD-LOT-1") + lot_2 = self._create_lot(self.product_flowable_1, "BKORD-LOT-2") + + # First reception — seed the location + self._seed_flowable_location( + self.location_flowable_1, self.product_flowable_1, lot_0, 100 + ) + self.assertFalse(self.location_flowable_1.flowable_blocked) + + # Create a picking with 2 move lines of different lots + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_incoming_1.id, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_flowable_1.id, + } + ) + for lot, qty in [(lot_1, 200), (lot_2, 150)]: + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot.id, + "qty_done": qty, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_flowable_1.id, + "company_id": self.env.company.id, + } + ) + + # ACT & ASSERT — pre-check rejects both lines at the same location + with self.assertRaises(UserError) as error: + picking.button_validate() + + msg_error = ( + "Cannot receive multiple product/lot combinations" + " (%s) at flowable location '%s' in the same" + " receipt." + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_backorder_validation_while_location_blocked(self): + """ + Test that validating a second reception while the location is still + blocked by an in-progress MO is rejected. + + Simulates 2 deliveries arriving at the warehouse: both pickings are + prepared before the first is validated. After validating the first + (which blocks the location), validating the second should fail. + + PRE: - Two incoming pickings prepared for the same flowable location + ACT: - Validate the first picking (blocks the location) + - Try to validate the second picking + POST: - Error about the location being blocked + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_1 = self._create_lot(self.product_flowable_1, "BLOCKED-BO-LOT-1") + lot_2 = self._create_lot(self.product_flowable_1, "BLOCKED-BO-LOT-2") + + # Both pickings are prepared before any validation + picking_1 = self._create_incoming_picking( + self.location_flowable_1, self.product_flowable_1, lot_1, 100 + ) + picking_2 = self._create_incoming_picking( + self.location_flowable_1, self.product_flowable_1, lot_2, 200 + ) + + # First reception — blocks the location + picking_1.button_validate() + self.assertTrue(self.location_flowable_1.flowable_blocked) + + # ACT & ASSERT — second reception rejected while location is blocked + with self.assertRaises(Exception): + picking_2.button_validate() + + def test_mixed_flowable_and_non_flowable_lines(self): + """ + Test that a picking with both flowable and non-flowable destination + lines validates correctly: the flowable line creates an MO, the + non-flowable line goes through normally. + + PRE: - A picking with one line to a flowable location and another + line to a non-flowable location + ACT: - Validate the picking + POST: - The flowable location is blocked with an MO + - The non-flowable location has stock (no MO) + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_flow = self._create_lot(self.product_flowable_1, "MIXED-FLOW-LOT") + lot_nonflow = self._create_lot(self.product_flowable_1, "MIXED-NONFLOW-LOT") + + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_incoming_1.id, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_flowable_1.id, + } + ) + # Line 1 → flowable location + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot_flow.id, + "qty_done": 50, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_flowable_1.id, + "company_id": self.env.company.id, + } + ) + # Line 2 → non-flowable location + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot_nonflow.id, + "qty_done": 30, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_1.id, + "company_id": self.env.company.id, + } + ) + + # ACT + picking.button_validate() + + # ASSERT + self.assertTrue(self.location_flowable_1.flowable_blocked) + production = self._find_flowable_production(self.location_flowable_1) + self.assertTrue(production) + + # Non-flowable location has stock, no MO + nonflow_quant = self.env["stock.quant"].search( + [ + ("location_id", "=", self.location_1.id), + ("product_id", "=", self.product_flowable_1.id), + ("lot_id", "=", lot_nonflow.id), + ] + ) + self.assertEqual(nonflow_quant.quantity, 30) + + def test_same_product_different_lots_different_locations_succeeds(self): + """ + Test that receiving the same product with different lots at different + flowable locations in one receipt succeeds (no conflict). + + PRE: - A picking with 2 lines: lot A → location_flowable_1, + lot B → location_flowable_2 + ACT: - Validate the picking + POST: - Both locations are blocked with separate MOs + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_a = self._create_lot(self.product_flowable_1, "DIFFLOC-LOT-A") + lot_b = self._create_lot(self.product_flowable_1, "DIFFLOC-LOT-B") + + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_incoming_1.id, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_flowable_1.id, + } + ) + # Line 1 → location_flowable_1 + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot_a.id, + "qty_done": 50, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_flowable_1.id, + "company_id": self.env.company.id, + } + ) + # Line 2 → location_flowable_2 + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot_b.id, + "qty_done": 50, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_flowable_2.id, + "company_id": self.env.company.id, + } + ) + + # ACT + picking.button_validate() + + # ASSERT — both locations are blocked independently + self.assertTrue(self.location_flowable_1.flowable_blocked) + self.assertTrue(self.location_flowable_2.flowable_blocked) + self.assertNotEqual( + self.location_flowable_1.flowable_production_id, + self.location_flowable_2.flowable_production_id, + ) + + def test_zero_qty_done_flowable_lines_ignored(self): + """ + Test that move lines with qty_done=0 at a flowable location are + ignored and don't create MOs or trigger conflicts. + + PRE: - A picking with 2 lines to a flowable location: one with + qty_done=50 and another with qty_done=0 + ACT: - Validate the picking + POST: - Only 1 MO is created (for the non-zero line) + - No pre-check error about multiple combinations + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_a = self._create_lot(self.product_flowable_1, "ZERO-QTY-LOT-A") + lot_b = self._create_lot(self.product_flowable_1, "ZERO-QTY-LOT-B") + + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_incoming_1.id, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_flowable_1.id, + } + ) + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot_a.id, + "qty_done": 50, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_flowable_1.id, + "company_id": self.env.company.id, + } + ) + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot_b.id, + "qty_done": 0, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_flowable_1.id, + "company_id": self.env.company.id, + } + ) + + # ACT + picking.button_validate() + + # ASSERT + productions = picking.flowable_production_ids + self.assertEqual(len(productions), 1) + self.assertTrue(self.location_flowable_1.flowable_blocked) + + def test_create_lots_true_topup_same_incoming_lot(self): + """ + Test top-up at a create_lots=True location where the incoming lot + is the same as the existing lot. The auto-generated producing lot + should still be different from the incoming lot. + + PRE: - Flowable location (create_lots=True) seeded with lot A + ACT: - Receive lot A again (top-up with same lot) + POST: - MO is created with an auto-generated producing lot + - The producing lot is different from lot A + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_a = self._create_lot(self.product_flowable_1, "TOPUP-SAME-LOT") + self._seed_flowable_location( + self.location_flowable_2, self.product_flowable_1, lot_a, 100 + ) + + # ACT — receive lot A again at the same location + self._receive_stock( + self.location_flowable_2, self.product_flowable_1, lot_a, 50 + ) + + # ASSERT + production = self._find_flowable_production(self.location_flowable_2) + self.assertTrue(production) + self.assertNotEqual( + production.lot_producing_id, + lot_a, + "Auto-generated lot should differ from the incoming lot", + ) diff --git a/stock_location_flowable/tests/test_stock_return_picking.py b/stock_location_flowable/tests/test_stock_return_picking.py index f00bfd8a6..7859d7045 100644 --- a/stock_location_flowable/tests/test_stock_return_picking.py +++ b/stock_location_flowable/tests/test_stock_return_picking.py @@ -120,3 +120,111 @@ def test_return_from_non_flowable_location_succeeds(self): self.assertTrue(new_picking_id) return_picking = self.env["stock.picking"].browse(new_picking_id) self.assertEqual(return_picking.state, "assigned") + + def test_return_from_mixed_flowable_non_flowable_picking(self): + """ + Test that returning from a picking that delivered to both a flowable + and a non-flowable location only blocks the return of the flowable + product. + + PRE: - A completed incoming picking with: + - Line 1: product to flowable location (MO completed) + - Line 2: product to non-flowable location + ACT: - Attempt to return the flowable product + POST: - UserError about the flowable product + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_flow = self._create_lot(self.product_flowable_1, "RET-MIXED-FLOW") + lot_nonflow = self._create_lot(self.product_flowable_1, "RET-MIXED-NOFLOW") + + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_incoming_1.id, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_flowable_1.id, + } + ) + # Line 1 → flowable location + move_flow = self.env["stock.move"].create( + { + "name": "Flowable line", + "picking_id": picking.id, + "product_id": self.product_flowable_1.id, + "product_uom": self.product_flowable_1.uom_id.id, + "product_uom_qty": 50, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_flowable_1.id, + } + ) + # Line 2 → non-flowable location + move_nonflow = self.env["stock.move"].create( + { + "name": "Non-flowable line", + "picking_id": picking.id, + "product_id": self.product_flowable_1.id, + "product_uom": self.product_flowable_1.uom_id.id, + "product_uom_qty": 30, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_1.id, + } + ) + picking.action_confirm() + picking.action_assign() + + for move, lot, qty in [ + (move_flow, lot_flow, 50), + (move_nonflow, lot_nonflow, 30), + ]: + ml = move.move_line_ids + if ml: + ml.write({"lot_id": lot.id, "qty_done": qty}) + else: + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "move_id": move.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot.id, + "qty_done": qty, + "location_id": self.supplier_location.id, + "location_dest_id": move.location_dest_id.id, + "company_id": self.env.company.id, + } + ) + + picking.button_validate() + + # Complete the MO so the location is unblocked + production = self._find_flowable_production(self.location_flowable_1) + if production: + production.button_mark_done() + + # ACT — try to return both products + return_wizard = ( + self.env["stock.return.picking"] + .with_context( + active_id=picking.id, + active_model="stock.picking", + ) + .create( + { + "picking_id": picking.id, + "location_id": self.supplier_location.id, + } + ) + ) + return_wizard._onchange_picking_id() + + # ASSERT — error mentions the flowable product + with self.assertRaises(UserError) as error: + return_wizard._create_returns() + + msg_error = ( + "You cannot return the following products because" + " they come from a flowable location: %s" + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) From 9f4e66e0b26fe299a60a674b7b96eaef9762519b Mon Sep 17 00:00:00 2001 From: ??? Date: Mon, 16 Mar 2026 09:37:33 +0100 Subject: [PATCH 29/31] [IMP] stock_location_flowable: pre-commit auto fixes --- stock_location_flowable/README.rst | 25 +++--- stock_location_flowable/__manifest__.py | 2 +- stock_location_flowable/pyproject.toml | 3 + .../readme/CONTRIBUTORS.md | 5 ++ .../readme/CONTRIBUTORS.rst | 6 -- stock_location_flowable/readme/DESCRIPTION.md | 2 + .../readme/DESCRIPTION.rst | 1 - .../static/description/index.html | 9 +- stock_location_flowable/tests/test_common.py | 2 +- .../tests/test_mrp_production.py | 6 +- .../tests/test_stock_location.py | 2 +- .../tests/test_stock_move.py | 2 +- .../tests/test_stock_move_line.py | 2 +- .../tests/test_stock_picking.py | 4 +- .../tests/test_stock_picking_type.py | 2 +- .../tests/test_stock_quant.py | 2 +- .../tests/test_stock_return_picking.py | 2 +- .../views/stock_location_views.xml | 3 +- .../views/stock_picking_views.xml | 87 +++++++++---------- 19 files changed, 85 insertions(+), 82 deletions(-) create mode 100644 stock_location_flowable/pyproject.toml create mode 100644 stock_location_flowable/readme/CONTRIBUTORS.md delete mode 100644 stock_location_flowable/readme/CONTRIBUTORS.rst create mode 100644 stock_location_flowable/readme/DESCRIPTION.md delete mode 100644 stock_location_flowable/readme/DESCRIPTION.rst diff --git a/stock_location_flowable/README.rst b/stock_location_flowable/README.rst index 3160ce8fc..ea4e420c6 100644 --- a/stock_location_flowable/README.rst +++ b/stock_location_flowable/README.rst @@ -17,12 +17,13 @@ Stock Location Flowable :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-NuoBiT%2Fodoo--addons-lightgray.png?logo=github - :target: https://github.com/NuoBiT/odoo-addons/tree/14.0/stock_location_flowable + :target: https://github.com/NuoBiT/odoo-addons/tree/18.0/stock_location_flowable :alt: NuoBiT/odoo-addons |badge1| |badge2| |badge3| -* Customizations that allow organizing, controlling, and mixing bulk liquid and solid products in a location. +- Customizations that allow organizing, controlling, and mixing bulk + liquid and solid products in a location. **Table of contents** @@ -35,7 +36,7 @@ Bug Tracker Bugs are tracked on `GitHub Issues `_. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed -`feedback `_. +`feedback `_. Do not contact contributors directly about support or help with technical issues. @@ -43,24 +44,24 @@ Credits ======= Authors -~~~~~~~ +------- * NuoBiT Solutions * S.L. Contributors -~~~~~~~~~~~~ +------------ -* `NuoBiT `__: +- `NuoBiT `__: - * Frank Cespedes - * Deniz Gallo - * Bijaya Kumal - * Eric Antones + - Frank Cespedes + - Deniz Gallo + - Bijaya Kumal + - Eric Antones Maintainers -~~~~~~~~~~~ +----------- -This module is part of the `NuoBiT/odoo-addons `_ project on GitHub. +This module is part of the `NuoBiT/odoo-addons `_ project on GitHub. You are welcome to contribute. diff --git a/stock_location_flowable/__manifest__.py b/stock_location_flowable/__manifest__.py index 5861f3f7c..020b5e4e3 100644 --- a/stock_location_flowable/__manifest__.py +++ b/stock_location_flowable/__manifest__.py @@ -7,7 +7,7 @@ " mixing bulk liquid and solid products in a location", "version": "14.0.1.0.1", "author": "NuoBiT Solutions, S.L.", - "website": "https://github.com/nuobit/odoo-addons", + "website": "https://github.com/NuoBiT/odoo-addons", "category": "Stock", "depends": ["mrp", "uom_rounding_coherence"], "license": "AGPL-3", diff --git a/stock_location_flowable/pyproject.toml b/stock_location_flowable/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/stock_location_flowable/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/stock_location_flowable/readme/CONTRIBUTORS.md b/stock_location_flowable/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..250a35811 --- /dev/null +++ b/stock_location_flowable/readme/CONTRIBUTORS.md @@ -0,0 +1,5 @@ +- [NuoBiT](https://www.nuobit.com): + - Frank Cespedes \ + - Deniz Gallo \ + - Bijaya Kumal \ + - Eric Antones \ diff --git a/stock_location_flowable/readme/CONTRIBUTORS.rst b/stock_location_flowable/readme/CONTRIBUTORS.rst deleted file mode 100644 index c18a29389..000000000 --- a/stock_location_flowable/readme/CONTRIBUTORS.rst +++ /dev/null @@ -1,6 +0,0 @@ -* `NuoBiT `__: - - * Frank Cespedes - * Deniz Gallo - * Bijaya Kumal - * Eric Antones diff --git a/stock_location_flowable/readme/DESCRIPTION.md b/stock_location_flowable/readme/DESCRIPTION.md new file mode 100644 index 000000000..f75fe770e --- /dev/null +++ b/stock_location_flowable/readme/DESCRIPTION.md @@ -0,0 +1,2 @@ +- Customizations that allow organizing, controlling, and mixing bulk + liquid and solid products in a location. diff --git a/stock_location_flowable/readme/DESCRIPTION.rst b/stock_location_flowable/readme/DESCRIPTION.rst deleted file mode 100644 index 6499f0e5d..000000000 --- a/stock_location_flowable/readme/DESCRIPTION.rst +++ /dev/null @@ -1 +0,0 @@ -* Customizations that allow organizing, controlling, and mixing bulk liquid and solid products in a location. diff --git a/stock_location_flowable/static/description/index.html b/stock_location_flowable/static/description/index.html index b7b680fda..b1c31656f 100644 --- a/stock_location_flowable/static/description/index.html +++ b/stock_location_flowable/static/description/index.html @@ -369,9 +369,10 @@

    Stock Location Flowable

    !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !! source digest: sha256:54ad28ddc49473710e7dd86ba1a9dfbf22c66c1f791fd97a5a03da055b977589 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> -

    Beta License: AGPL-3 NuoBiT/odoo-addons

    +

    Beta License: AGPL-3 NuoBiT/odoo-addons

      -
    • Customizations that allow organizing, controlling, and mixing bulk liquid and solid products in a location.
    • +
    • Customizations that allow organizing, controlling, and mixing bulk +liquid and solid products in a location.

    Table of contents

    @@ -390,7 +391,7 @@

    Bug Tracker

    Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed -feedback.

    +feedback.

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

    @@ -416,7 +417,7 @@

    Contributors

    Maintainers

    -

    This module is part of the NuoBiT/odoo-addons project on GitHub.

    +

    This module is part of the NuoBiT/odoo-addons project on GitHub.

    You are welcome to contribute.

    diff --git a/stock_location_flowable/tests/test_common.py b/stock_location_flowable/tests/test_common.py index d1a6d437f..de8f32f5a 100644 --- a/stock_location_flowable/tests/test_common.py +++ b/stock_location_flowable/tests/test_common.py @@ -12,7 +12,7 @@ class TestCommon(common.SavepointCase): @classmethod def setUpClass(cls): - super(TestCommon, cls).setUpClass() + super().setUpClass() cls.supplier_location = cls.env.ref("stock.stock_location_suppliers") diff --git a/stock_location_flowable/tests/test_mrp_production.py b/stock_location_flowable/tests/test_mrp_production.py index 5bdb6e9ea..174510804 100644 --- a/stock_location_flowable/tests/test_mrp_production.py +++ b/stock_location_flowable/tests/test_mrp_production.py @@ -15,7 +15,7 @@ class TestMrpProduction(TestCommon): @classmethod def setUpClass(cls): - super(TestMrpProduction, cls).setUpClass() + super().setUpClass() def test_blocked_flowable_mrp_operation(self): # ARRANGE @@ -746,7 +746,7 @@ def test_production_without_bom_allowed_for_flowable(self): class TestFlowableReservationConflictFromProduction(TestCommon): @classmethod def setUpClass(cls): - super(TestFlowableReservationConflictFromProduction, cls).setUpClass() + super().setUpClass() cls.picking_type_mrp = cls.env["stock.picking.type"].create( { @@ -878,7 +878,7 @@ def test_reservation_conflict_from_production_move(self): class TestFlowableBlockingWithReservations(TestCommon): @classmethod def setUpClass(cls): - super(TestFlowableBlockingWithReservations, cls).setUpClass() + super().setUpClass() cls.picking_type_mrp = cls.env["stock.picking.type"].create( { diff --git a/stock_location_flowable/tests/test_stock_location.py b/stock_location_flowable/tests/test_stock_location.py index beeac59a5..56b722fb4 100644 --- a/stock_location_flowable/tests/test_stock_location.py +++ b/stock_location_flowable/tests/test_stock_location.py @@ -14,7 +14,7 @@ class TestStockLocation(TestCommon): @classmethod def setUpClass(cls): - super(TestStockLocation, cls).setUpClass() + super().setUpClass() cls.uom_litre = cls.env.ref("uom.product_uom_litre") cls.uom_unit = cls.env.ref("uom.product_uom_unit") cls.product_flowable_1 = cls.env["product.product"].create( diff --git a/stock_location_flowable/tests/test_stock_move.py b/stock_location_flowable/tests/test_stock_move.py index 6fb612d20..ad11e0f95 100644 --- a/stock_location_flowable/tests/test_stock_move.py +++ b/stock_location_flowable/tests/test_stock_move.py @@ -13,7 +13,7 @@ class TestStockMove(TestCommon): @classmethod def setUpClass(cls): - super(TestStockMove, cls).setUpClass() + super().setUpClass() def test_modify_in_progress_flowable_move_raises_error(self): """ diff --git a/stock_location_flowable/tests/test_stock_move_line.py b/stock_location_flowable/tests/test_stock_move_line.py index 21e3a64b7..4e1944e93 100644 --- a/stock_location_flowable/tests/test_stock_move_line.py +++ b/stock_location_flowable/tests/test_stock_move_line.py @@ -13,7 +13,7 @@ class TestStockMoveLine(TestCommon): @classmethod def setUpClass(cls): - super(TestStockMoveLine, cls).setUpClass() + super().setUpClass() def test_blocked_location_rejects_unrelated_production_done(self): """ diff --git a/stock_location_flowable/tests/test_stock_picking.py b/stock_location_flowable/tests/test_stock_picking.py index 39ed0a41c..c47b58381 100644 --- a/stock_location_flowable/tests/test_stock_picking.py +++ b/stock_location_flowable/tests/test_stock_picking.py @@ -14,7 +14,7 @@ class TestStockPicking(TestCommon): @classmethod def setUpClass(cls): - super(TestStockPicking, cls).setUpClass() + super().setUpClass() cls.incoming_picking = cls.env["stock.picking"].create( { @@ -970,7 +970,7 @@ def test_backorder_cascade_three_lots(self): completed_productions = self.env["mrp.production"] - for step, (lot, qty) in enumerate(zip(lots, qtys)): + for step, (lot, qty) in enumerate(zip(lots, qtys, strict=False)): picking = self._create_incoming_picking( self.location_flowable_1, self.product_flowable_1, lot, qty ) diff --git a/stock_location_flowable/tests/test_stock_picking_type.py b/stock_location_flowable/tests/test_stock_picking_type.py index b452105d1..b9ffcfbd0 100644 --- a/stock_location_flowable/tests/test_stock_picking_type.py +++ b/stock_location_flowable/tests/test_stock_picking_type.py @@ -14,7 +14,7 @@ class TestStockPickingType(TestCommon): @classmethod def setUpClass(cls): - super(TestStockPickingType, cls).setUpClass() + super().setUpClass() def test_unique_flowable_operation_picking_type(self): # ARRANGE diff --git a/stock_location_flowable/tests/test_stock_quant.py b/stock_location_flowable/tests/test_stock_quant.py index 58622e63b..14ebcc52d 100644 --- a/stock_location_flowable/tests/test_stock_quant.py +++ b/stock_location_flowable/tests/test_stock_quant.py @@ -13,7 +13,7 @@ class TestStockQuant(TestCommon): @classmethod def setUpClass(cls): - super(TestStockQuant, cls).setUpClass() + super().setUpClass() def test_unique_lot_constraint_at_flowable_location(self): """ diff --git a/stock_location_flowable/tests/test_stock_return_picking.py b/stock_location_flowable/tests/test_stock_return_picking.py index 7859d7045..85993bb80 100644 --- a/stock_location_flowable/tests/test_stock_return_picking.py +++ b/stock_location_flowable/tests/test_stock_return_picking.py @@ -13,7 +13,7 @@ class TestStockReturnPicking(TestCommon): @classmethod def setUpClass(cls): - super(TestStockReturnPicking, cls).setUpClass() + super().setUpClass() def test_return_from_flowable_location_raises_error(self): """ diff --git a/stock_location_flowable/views/stock_location_views.xml b/stock_location_flowable/views/stock_location_views.xml index ab4e43fb0..59c68dea5 100644 --- a/stock_location_flowable/views/stock_location_views.xml +++ b/stock_location_flowable/views/stock_location_views.xml @@ -26,8 +26,7 @@ type="object" attrs="{'invisible': [('flowable_production_id', '=', False)]}" groups="stock.group_stock_user" - > - + /> diff --git a/stock_location_flowable/views/stock_picking_views.xml b/stock_location_flowable/views/stock_picking_views.xml index 7ce36b721..7e236c79f 100644 --- a/stock_location_flowable/views/stock_picking_views.xml +++ b/stock_location_flowable/views/stock_picking_views.xml @@ -16,53 +16,52 @@ type="object" attrs="{'invisible': [('flowable_production_ids', '=', [])]}" groups="stock.group_stock_user" - > - + /> - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + From 330129eacd730ed04be29aad6ee1256da131e72d Mon Sep 17 00:00:00 2001 From: ??? Date: Mon, 16 Mar 2026 09:58:12 +0100 Subject: [PATCH 30/31] [MIG] stock_location_flowable: Migration to 18.0 --- stock_location_flowable/README.rst | 11 +- stock_location_flowable/__manifest__.py | 6 +- .../models/mrp_production.py | 71 +++++----- .../models/stock_location.py | 42 +++--- stock_location_flowable/models/stock_move.py | 18 +-- .../models/stock_move_line.py | 10 +- .../models/stock_picking.py | 123 +++++++++++------- .../models/stock_picking_type.py | 10 +- stock_location_flowable/models/stock_quant.py | 5 +- .../models/stock_return_picking.py | 16 ++- .../readme/CONTRIBUTORS.md | 8 +- .../static/description/index.html | 11 +- stock_location_flowable/tests/test_common.py | 68 +++++----- .../tests/test_mrp_production.py | 89 +++++++------ .../tests/test_stock_location.py | 40 +++--- .../tests/test_stock_move.py | 17 +-- .../tests/test_stock_move_line.py | 9 +- .../tests/test_stock_picking.py | 105 +++++++++------ .../tests/test_stock_picking_type.py | 5 +- .../tests/test_stock_quant.py | 27 ++-- .../tests/test_stock_return_picking.py | 25 ++-- .../views/mrp_production_views.xml | 22 ++-- .../views/stock_location_views.xml | 33 +++-- .../views/stock_move_views.xml | 7 +- .../views/stock_picking_type_views.xml | 6 +- .../views/stock_picking_views.xml | 3 +- 26 files changed, 423 insertions(+), 364 deletions(-) diff --git a/stock_location_flowable/README.rst b/stock_location_flowable/README.rst index ea4e420c6..437534fbc 100644 --- a/stock_location_flowable/README.rst +++ b/stock_location_flowable/README.rst @@ -46,18 +46,17 @@ Credits Authors ------- -* NuoBiT Solutions -* S.L. +* NuoBiT Solutions SL Contributors ------------ - `NuoBiT `__: - - Frank Cespedes - - Deniz Gallo - - Bijaya Kumal - - Eric Antones + - Frank Cespedes fcespedes@nuobit.com + - Deniz Gallo dgallo@nuobit.com + - Bijaya Kumal bkumal@nuobit.com + - Eric Antones eantones@nuobit.com Maintainers ----------- diff --git a/stock_location_flowable/__manifest__.py b/stock_location_flowable/__manifest__.py index 020b5e4e3..fd4910a92 100644 --- a/stock_location_flowable/__manifest__.py +++ b/stock_location_flowable/__manifest__.py @@ -1,12 +1,12 @@ # Copyright NuoBiT Solutions - Frank Cespedes +# Copyright 2026 NuoBiT Solutions SL- Deniz Gallo # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) - { "name": "Stock Location Flowable", "summary": "Customizations that allow organizing, controlling, and" " mixing bulk liquid and solid products in a location", - "version": "14.0.1.0.1", - "author": "NuoBiT Solutions, S.L.", + "version": "18.0.1.0.1", + "author": "NuoBiT Solutions SL", "website": "https://github.com/NuoBiT/odoo-addons", "category": "Stock", "depends": ["mrp", "uom_rounding_coherence"], diff --git a/stock_location_flowable/models/mrp_production.py b/stock_location_flowable/models/mrp_production.py index 597fb5037..b9e29e31e 100644 --- a/stock_location_flowable/models/mrp_production.py +++ b/stock_location_flowable/models/mrp_production.py @@ -1,5 +1,5 @@ -# Copyright NuoBiT - Frank Cespedes -# Copyright 2025 NuoBiT - Deniz Gallo +# Copyright NuoBiT Solutions SL - Frank Cespedes +# Copyright 2026 NuoBiT Solutions SL - Deniz Gallo # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) from odoo import _, api, fields, models @@ -26,12 +26,6 @@ def _compute_production_blocked(self): ) ) - @api.constrains("product_id", "move_raw_ids", "location_dest_id") - def _check_production_lines(self): - for rec in self: - if not rec.picking_type_id.flowable_operation: - super(MrpProduction, rec)._check_production_lines() - @api.constrains("state") def _check_flowable_blocked(self): for rec in self: @@ -87,17 +81,19 @@ def _check_flowable_post_production_quants(self): if not float_is_zero(quant.quantity, precision_rounding=rounding): raise ValidationError( _( - "After completing the mixing order '%s' at" - " flowable location '%s', lot '%s' still has" - " %s %s of stock. Expected 0 after merging" - " all raw materials into lot '%s'.", - self.name, - location.name, - quant.lot_id.name, - quant.quantity, - self.product_uom_id.name, - self.lot_producing_id.name, + "After completing the mixing order '%(order)s' at" + " flowable location '%(location)s', lot '%(lot)s' still has" + " %(qty)s %(uom)s of stock. Expected 0 after merging" + " all raw materials into lot '%(producing_lot)s'." ) + % { + "order": self.name, + "location": location.name, + "lot": quant.lot_id.name, + "qty": quant.quantity, + "uom": self.product_uom_id.name, + "producing_lot": self.lot_producing_id.name, + } ) def action_assign(self): @@ -119,7 +115,7 @@ def _check_flowable_reservation(self): reserved_move_lines = self.env["stock.move.line"].search( [ ("location_id", "=", location.id), - ("product_uom_qty", ">", 0), + ("quantity_product_uom", ">", 0), ("state", "not in", ("done", "cancel", "draft")), ("move_id.raw_material_production_id", "!=", self.id), ] @@ -129,22 +125,21 @@ def _check_flowable_reservation(self): for ml in reserved_move_lines: move = ml.move_id if move.picking_id: - origin = "%s (%s)" % ( - move.picking_id.name, - move.picking_id.picking_type_id.name, + origin = ( + f"{move.picking_id.name}" + f" ({move.picking_id.picking_type_id.name})" ) elif move.raw_material_production_id: - origin = "%s (%s)" % ( - move.raw_material_production_id.name, - move.raw_material_production_id.picking_type_id.name, + origin = ( + f"{move.raw_material_production_id.name}" + f" ({move.raw_material_production_id.picking_type_id.name})" ) else: origin = _("Unknown origin (move %s)", move.id) details.append( - " - %s: %s %s (lot %s) - %s" - % ( + " - {}: {} {} (lot {}) - {}".format( ml.product_id.display_name, - ml.product_uom_qty, + ml.quantity_product_uom, ml.product_uom_id.name, ml.lot_id.name or _("no lot"), origin, @@ -152,23 +147,27 @@ def _check_flowable_reservation(self): ) raise UserError( _( - "Cannot merge at flowable location '%s'" + "Cannot merge at flowable location '%(location)s'" " because there are reserved quantities." " After the merge, the current lot(s) will" " have 0 stock and these reservations will" " become invalid.\n\n" "The following operations must be unreserved" - " or completed first:\n\n%s", - location.name, - "\n".join(details), + " or completed first:\n\n%(details)s" ) + % { + "location": location.name, + "details": "\n".join(details), + } ) raise UserError( _( "Cannot fully reserve the mixing order at" - " flowable location '%s'. Raw materials are" - " in state '%s'.", - location.name, - ", ".join(self.move_raw_ids.mapped("state")), + " flowable location '%(location)s'. Raw materials are" + " in state '%(states)s'." ) + % { + "location": location.name, + "states": ", ".join(self.move_raw_ids.mapped("state")), + } ) diff --git a/stock_location_flowable/models/stock_location.py b/stock_location_flowable/models/stock_location.py index f1c2471be..f4725dc87 100644 --- a/stock_location_flowable/models/stock_location.py +++ b/stock_location_flowable/models/stock_location.py @@ -1,5 +1,6 @@ -# Copyright NuoBiT Solutions - Frank Cespedes -# Copyright 2026 NuoBiT Solutions - Eric Antones +# Copyright NuoBiT Solutions SL - Frank Cespedes +# Copyright 2025 NuoBiT Solutions SL - Deniz Gallo +# Copyright 2026 NuoBiT Solutions SL- Deniz Gallo # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) from odoo import _, api, fields, models @@ -7,7 +8,7 @@ from odoo.tools.safe_eval import json -class Location(models.Model): +class StockLocation(models.Model): _inherit = "stock.location" flowable_storage = fields.Boolean() @@ -85,7 +86,8 @@ def _check_flowable_uom_id(self): for rec in self: if rec.flowable_storage: if rec.quant_ids.filtered( - lambda x: x.product_uom_id != rec.flowable_uom_id and x.quantity > 0 + lambda x, rec=rec: x.product_uom_id != rec.flowable_uom_id + and x.quantity > 0 ): raise ValidationError( _("You have stock movements with different unit of measure") @@ -145,23 +147,27 @@ def _check_sequence_products_flowable_capacity(self): if product.uom_id != rec.flowable_uom_id: raise ValidationError( _( - "The product %s is measured in %s. You can only assign" + "The product %(product)s is measured in %(uom)s." + " You can only assign" " products that have the allowed unit of measure" ) - % (product.name, product.uom_id.name) + % { + "product": product.name, + "uom": product.uom_id.name, + } ) @api.depends("name", "location_id.complete_name", "usage", "flowable_blocked") def _compute_complete_name(self): for rec in self: if rec.flowable_storage and rec.flowable_blocked: - rec.complete_name = "%s/%s [%s]" % ( + rec.complete_name = "{}/{} [{}]".format( rec.location_id.complete_name, rec.name, _("Blocked"), ) else: - super(Location, rec)._compute_complete_name() + return super(StockLocation, rec)._compute_complete_name() def name_get(self): res = [] @@ -183,13 +189,15 @@ def write(self, vals): if vals.get("flowable_storage"): for product in rec.quant_ids.product_id: product_quant = rec.quant_ids.filtered( - lambda x: x.quantity > 0 and x.product_id == product + lambda x, product=product: x.quantity > 0 + and x.product_id == product ) if len(product_quant) > 1: raise UserError( _( - "You cannot convert this location into a flowable location" - " because there are unmixed products." + "You cannot convert this location into" + " a flowable location because there" + " are unmixed products." ) ) if product.uom_id.id != vals.get( @@ -197,9 +205,10 @@ def write(self, vals): ): raise UserError( _( - "You cannot convert this location into a flowable location" - " because there are products with different units of" - " measure." + "You cannot convert this location into" + " a flowable location because there" + " are products with different units" + " of measure." ) ) elif not vals.get("flowable_storage", True): @@ -211,14 +220,15 @@ def write(self, vals): "flowable_uom_id": False, } ) - res &= super(Location, rec).write(vals) + res &= super(StockLocation, rec).write(vals) if rec.flowable_storage: removed_product_ids = set(old_allowed_products.ids) - set( rec.flowable_allowed_product_ids.ids ) for product_id in removed_product_ids: if rec.quant_ids.filtered( - lambda x: x.product_id.id == product_id and x.quantity > 0 + lambda x, pid=product_id: x.product_id.id == pid + and x.quantity > 0 ): raise UserError( _( diff --git a/stock_location_flowable/models/stock_move.py b/stock_location_flowable/models/stock_move.py index 59e6843e7..c381d1a3b 100644 --- a/stock_location_flowable/models/stock_move.py +++ b/stock_location_flowable/models/stock_move.py @@ -1,8 +1,8 @@ -# Copyright NuoBiT Solutions - Frank Cespedes +# Copyright NuoBiT Solutions SL - Frank Cespedes +# Copyright 2025 NuoBiT Solutions SL - Deniz Gallo # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) -from odoo import _, models -from odoo.exceptions import UserError +from odoo import models class StockMove(models.Model): @@ -19,18 +19,6 @@ def write(self, vals): if not production.picking_type_id.flowable_operation: continue new_state = vals.get("state") - # Guard: block all modifications during active mixing - if ( - production.picking_id - and production.state == "to_close" - and new_state != "done" - ): - raise UserError( - _( - "You cannot modify a production with a picking associated." - " The mixing is in progress." - ) - ) if not new_state: continue # Block location when MO raw materials are fully reserved diff --git a/stock_location_flowable/models/stock_move_line.py b/stock_location_flowable/models/stock_move_line.py index 2728240b2..cad4e1375 100644 --- a/stock_location_flowable/models/stock_move_line.py +++ b/stock_location_flowable/models/stock_move_line.py @@ -1,4 +1,5 @@ -# Copyright NuoBiT Solutions - Frank Cespedes +# Copyright NuoBiT Solutions SL - Frank Cespedes +# Copyright 2025 NuoBiT Solutions SL - Deniz Gallo # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) from odoo import _, api, fields, models @@ -38,9 +39,10 @@ def _check_flowable_location_blocked(self): ): raise ValidationError( _( - "The location %s is blocked. Probably you need to" - " review the pending manufacturing orders related" + "The location %(location)s is blocked." + " Probably you need to review the pending" + " manufacturing orders related" " to this location" ) - % location.name + % {"location": location.name} ) diff --git a/stock_location_flowable/models/stock_picking.py b/stock_location_flowable/models/stock_picking.py index 016fa2e53..b24fe3d98 100644 --- a/stock_location_flowable/models/stock_picking.py +++ b/stock_location_flowable/models/stock_picking.py @@ -1,5 +1,5 @@ -# Copyright NuoBiT Solutions - Frank Cespedes -# Copyright 2026 NuoBiT Solutions - Eric Antones +# Copyright NuoBiT Solutions SL - Frank Cespedes +# Copyright 2025 NuoBiT Solutions SL - Deniz Gallo # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) from odoo import _, fields, models @@ -29,12 +29,12 @@ def action_view_mrp_production(self): action["context"] = {**self.env.context, "search_default_todo": False} return action - def _prepare_lot_values(self, product, location_dest, qty_done): + def _prepare_lot_values(self, product, location_dest, quantity): self.ensure_one() return { "name": location_dest.flowable_sequence_id._next(), "product_id": product.id, - "product_qty": qty_done, + "product_qty": quantity, "product_uom_id": product.uom_id.id, } @@ -43,7 +43,8 @@ def _prepare_production_move_line_values(self, move_line, product, location_dest return { "lot_id": move_line.lot_id.id, "product_id": product.id, - "qty_done": move_line.quantity, + "quantity": move_line.quantity, + "picked": True, "product_uom_id": product.uom_id.id, "location_id": location_dest.id, "location_dest_id": product.with_company( @@ -99,10 +100,14 @@ def button_validate(self): return super().button_validate() def _action_done(self): - res = super( - StockPicking, - self.with_context(flowable_skip_trigger_assign=True), - )._action_done() + # has_flowable = any( + # ml.location_dest_id.flowable_storage + # for rec in self + # for ml in rec.move_line_ids_without_package + # ) + # if has_flowable: + # self = self.with_context(flowable_skip_trigger_assign=True) + res = super()._action_done() for rec in self: flowable_lines = rec.move_line_ids_without_package.filtered( lambda x: x.location_dest_id.flowable_storage @@ -122,26 +127,26 @@ def _action_done(self): raise UserError( _( "Not found flowable manufacturing picking type" - " in warehouse %s" + " in warehouse %(warehouse)s" ) - % rec.picking_type_id.warehouse_id.name + % {"warehouse": rec.picking_type_id.warehouse_id.name} ) elif len(mrp_operation_type) > 1: raise UserError( _( "More than one flowable manufacturing picking type" - " in warehouse %s" + " in warehouse %(warehouse)s" ) - % rec.picking_type_id.warehouse_id.name + % {"warehouse": rec.picking_type_id.warehouse_id.name} ) else: if not mrp_operation_type.sequence_id: raise UserError( _( "Not found sequence in flowable manufacturing" - " picking type %s" + " picking type %(picking_type)s" ) - % mrp_operation_type.display_name + % {"picking_type": mrp_operation_type.display_name} ) # Group move lines by (product, dest location, lot) and check for @@ -152,43 +157,58 @@ def _action_done(self): lines = {} for line in flowable_lines: key = (line.product_id, line.location_dest_id, line.lot_id) - lines[key] = lines.get(key, 0) + line.qty_done + lines[key] = lines.get(key, 0) + line.quantity existing = [k for k in lines if k[1] == line.location_dest_id] if len(existing) > 1: details = ", ".join( - "%s (%s)" % (k[0].name, k[2].name) if k[2] else k[0].name + f"{k[0].name} ({k[2].name})" if k[2] else k[0].name for k in existing ) raise UserError( _( "Cannot receive multiple product/lot combinations" - " (%s) at flowable location '%s' in the same" + " (%(details)s) at flowable location '%(location)s'" + " in the same" " receipt. Each combination generates a separate" " mixing order and the location is blocked after" " the first one. Create a backorder to receive" " them in separate steps." ) - % (details, line.location_dest_id.name) + % { + "details": details, + "location": line.location_dest_id.name, + } ) # create manufacturing orders - for (product, location_dest, lot), qty_done in lines.items(): + for (product, location_dest, lot), quantity in lines.items(): if product not in location_dest.flowable_allowed_product_ids: raise UserError( - _("Product %s not allowed in flowable location %s") - % (product.name, location_dest.name) + _( + "Product %(product)s not allowed" + " in flowable location %(location)s" + ) + % { + "product": product.name, + "location": location_dest.name, + } ) if product.uom_id != location_dest.flowable_uom_id: raise UserError( _( - "The allowed products %s cannot have different Unit of" - " Measure than flowable location %s" + "The allowed products %(product)s cannot have" + " different Unit of Measure than" + " flowable location %(location)s" ) - % (product.name, location_dest.name) + % { + "product": product.name, + "location": location_dest.name, + } ) if product.tracking != "lot": raise UserError( - _("Product %s must be tracked by lot") % product.name + _("Product %(product)s must be tracked by lot") + % {"product": product.name} ) component_quant = rec.env["stock.quant"].search( [ @@ -207,16 +227,14 @@ def _action_done(self): product, location_dest, quantity_to_prod, mrp_operation_type ) ) - production._onchange_move_finished_product() - production._onchange_move_finished() - production._onchange_location_dest() production.action_confirm() if location_dest.flowable_create_lots: - producing_lot = rec.env["stock.production.lot"].create( - rec._prepare_lot_values(product, location_dest, qty_done) + lot = rec.env["stock.lot"].create( + rec._prepare_lot_values(product, location_dest, quantity) ) - else: - producing_lot = lot + production.lot_producing_id = lot + production.action_assign() + production.move_raw_ids.move_line_ids.unlink() vals = [] for move_line in component_quant: vals.append( @@ -229,8 +247,6 @@ def _action_done(self): ) ) production.move_raw_ids.move_line_ids = vals - production.lot_producing_id = producing_lot - production.action_assign() production.qty_producing = quantity_to_prod return res @@ -240,29 +256,42 @@ def _check_flowable_quant_count(self, quants, location, product, lot): if len(quants) != 1: raise UserError( _( - "Initial reception at flowable location '%s'" - " for product '%s': expected 1 positive quant" - " (empty tank) but found %d." + "Initial reception at flowable location '%(location)s'" + " for product '%(product)s': expected 1 positive quant" + " (empty tank) but found %(count)d." ) - % (location.name, product.name, len(quants)) + % { + "location": location.name, + "product": product.name, + "count": len(quants), + } ) else: if len(quants) != 2: raise UserError( _( - "Mixing reception at flowable location '%s'" - " for product '%s': expected 2 positive quants" - " but found %d." + "Mixing reception at flowable location '%(location)s'" + " for product '%(product)s': expected 2 positive quants" + " but found %(count)d." ) - % (location.name, product.name, len(quants)) + % { + "location": location.name, + "product": product.name, + "count": len(quants), + } ) received = quants.filtered(lambda q: q.lot_id == lot) if len(received) != 1: raise UserError( _( - "Mixing reception at flowable location '%s'" - " for product '%s': expected exactly 1 quant" - " for the received lot '%s' but found %d." + "Mixing reception at flowable location '%(location)s'" + " for product '%(product)s': expected exactly 1 quant" + " for the received lot '%(lot)s' but found %(count)d." ) - % (location.name, product.name, lot.name, len(received)) + % { + "location": location.name, + "product": product.name, + "lot": lot.name, + "count": len(received), + } ) diff --git a/stock_location_flowable/models/stock_picking_type.py b/stock_location_flowable/models/stock_picking_type.py index 4a0d94b78..02a6f8019 100644 --- a/stock_location_flowable/models/stock_picking_type.py +++ b/stock_location_flowable/models/stock_picking_type.py @@ -1,4 +1,5 @@ -# Copyright NuoBiT Solutions - Frank Cespedes +# Copyright NuoBiT Solutions SL - Frank Cespedes +# Copyright 2025 NuoBiT Solutions SL - Deniz Gallo # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) from odoo import _, api, fields, models @@ -30,6 +31,9 @@ def _check_flowable_operation(self): > 1 ): raise ValidationError( - _("Only one picking type can be flowable in a warehouse %s.") - % rec.warehouse_id.name, + _( + "Only one picking type can be flowable " + "in a warehouse %(warehouse_name)s." + ) + % {"warehouse_name": rec.warehouse_id.name} ) diff --git a/stock_location_flowable/models/stock_quant.py b/stock_location_flowable/models/stock_quant.py index 5e8565be8..c325e5a1c 100644 --- a/stock_location_flowable/models/stock_quant.py +++ b/stock_location_flowable/models/stock_quant.py @@ -1,4 +1,5 @@ -# Copyright NuoBiT Solutions - Frank Cespedes +# Copyright NuoBiT Solutions SL - Frank Cespedes +# Copyright 2025 NuoBiT Solutions SL - Deniz Gallo # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) from odoo import _, api, models @@ -17,7 +18,7 @@ def _check_unique_lot(self): if ( len( rec.product_id.stock_quant_ids.filtered( - lambda x: float_compare( + lambda x, rec=rec: float_compare( x.quantity, 0, precision_rounding=rec.product_uom_id.rounding, diff --git a/stock_location_flowable/models/stock_return_picking.py b/stock_location_flowable/models/stock_return_picking.py index ca5d655fe..45a302a9c 100644 --- a/stock_location_flowable/models/stock_return_picking.py +++ b/stock_location_flowable/models/stock_return_picking.py @@ -1,5 +1,6 @@ -# Copyright NuoBiT Solutions - Frank Cespedes -# Copyright 2026 NuoBiT Solutions - Eric Antones +# Copyright NuoBiT Solutions SL - Frank Cespedes +# Copyright 2025 NuoBiT Solutions SL - Deniz Gallo +# Copyright 2026 NuoBiT Solutions SL- Deniz Gallo # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) from odoo import _, models @@ -9,15 +10,20 @@ class ReturnPicking(models.TransientModel): _inherit = "stock.return.picking" - def _create_returns(self): + def _create_return(self): self.ensure_one() move_line = self.picking_id.move_line_ids_without_package.filtered( lambda x: x.location_dest_id.flowable_storage and x.product_id in self.product_return_moves.product_id ) if move_line: + detail_tpl = _("%(product)s (%(location)s)") details = ", ".join( - _("%s (%s)") % (ml.product_id.name, ml.location_dest_id.name) + detail_tpl + % { + "product": ml.product_id.name, + "location": ml.location_dest_id.name, + } for ml in move_line ) raise UserError( @@ -27,4 +33,4 @@ def _create_returns(self): ) % details ) - return super()._create_returns() + return super()._create_return() diff --git a/stock_location_flowable/readme/CONTRIBUTORS.md b/stock_location_flowable/readme/CONTRIBUTORS.md index 250a35811..b7d9bad9b 100644 --- a/stock_location_flowable/readme/CONTRIBUTORS.md +++ b/stock_location_flowable/readme/CONTRIBUTORS.md @@ -1,5 +1,5 @@ - [NuoBiT](https://www.nuobit.com): - - Frank Cespedes \ - - Deniz Gallo \ - - Bijaya Kumal \ - - Eric Antones \ + - Frank Cespedes + - Deniz Gallo + - Bijaya Kumal + - Eric Antones diff --git a/stock_location_flowable/static/description/index.html b/stock_location_flowable/static/description/index.html index b1c31656f..04c436945 100644 --- a/stock_location_flowable/static/description/index.html +++ b/stock_location_flowable/static/description/index.html @@ -399,18 +399,17 @@

    Credits

    Authors

      -
    • NuoBiT Solutions
    • -
    • S.L.
    • +
    • NuoBiT Solutions SL

    Contributors

    diff --git a/stock_location_flowable/tests/test_common.py b/stock_location_flowable/tests/test_common.py index de8f32f5a..23d6f5f2b 100644 --- a/stock_location_flowable/tests/test_common.py +++ b/stock_location_flowable/tests/test_common.py @@ -1,4 +1,5 @@ -# Copyright NuoBiT Solutions - Frank Cespedes +# Copyright NuoBiT Solutions SL - Frank Cespedes +# Copyright 2025 NuoBiT Solutions SL - Deniz Gallo # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import logging @@ -9,7 +10,7 @@ _logger = logging.getLogger(__name__) -class TestCommon(common.SavepointCase): +class TestCommon(common.TransactionCase): @classmethod def setUpClass(cls): super().setUpClass() @@ -32,6 +33,7 @@ def setUpClass(cls): "name": "Delivery1", "sequence_code": "SEQ-OUT", "code": "outgoing", + "reservation_method": "manual", "default_location_src_id": cls.env.ref( "stock.stock_location_locations_partner" ).id, @@ -57,13 +59,15 @@ def setUpClass(cls): "name": "Production1", "sequence_code": "SEQ-MRP", "code": "mrp_operation", + "flowable_operation": False, } ) cls.product_flowable_1 = cls.env["product.product"].create( { "name": "Liquid O2", - "type": "product", + "type": "consu", + "is_storable": True, "uom_id": cls.env.ref("uom.product_uom_litre").id, "uom_po_id": cls.env.ref("uom.product_uom_litre").id, "tracking": "lot", @@ -73,7 +77,8 @@ def setUpClass(cls): cls.product_flowable_2 = cls.env["product.product"].create( { "name": "Liquid N2", - "type": "product", + "type": "consu", + "is_storable": True, "uom_id": cls.env.ref("uom.product_uom_litre").id, "uom_po_id": cls.env.ref("uom.product_uom_litre").id, "tracking": "lot", @@ -121,11 +126,13 @@ def setUpClass(cls): "flowable_uom_id": cls.env.ref("uom.product_uom_litre").id, "flowable_allowed_product_ids": [(4, cls.product_flowable_1.id)], "flowable_create_lots": True, - "flowable_sequence_id": cls.flowable_sequence.id, + "flowable_sequence_id": cls.env.ref( + "stock.sequence_production_lots" + ).id, } ) - lot_1 = cls.env["stock.production.lot"].create( + lot_1 = cls.env["stock.lot"].create( { "name": "Lot1", "product_id": cls.product_flowable_1.id, @@ -146,7 +153,7 @@ def setUpClass(cls): "product_id": cls.product_flowable_1.id, "product_uom_id": cls.product_flowable_1.uom_id.id, "lot_id": lot_1.id, - "qty_done": 10, + "quantity": 10, "location_id": cls.supplier_location.id, "location_dest_id": cls.incoming_picking.location_dest_id.id, "company_id": cls.env.company.id, @@ -167,7 +174,7 @@ def setUpClass(cls): "product_id": cls.product_flowable_1.id, "product_uom_id": cls.product_flowable_1.uom_id.id, "lot_id": lot_1.id, - "qty_done": 10, + "quantity": 10, "location_id": cls.supplier_location.id, "location_dest_id": cls.outgoing_picking.location_dest_id.id, "company_id": cls.env.company.id, @@ -188,8 +195,8 @@ def setUpClass(cls): "product_id": cls.product_flowable_1.id, "product_uom_id": cls.product_flowable_1.uom_id.id, "lot_id": lot_1.id, - "qty_done": 10, - "location_id": cls.env.ref("stock.stock_location_inter_wh").id, + "quantity": 10, + "location_id": cls.env.ref("stock.stock_location_inter_company").id, "location_dest_id": cls.internal_picking.location_dest_id.id, "company_id": cls.env.company.id, } @@ -204,13 +211,12 @@ def setUpClass(cls): ) def get_error_message_regex(self, str1): - parts = str1.split("%s") - escaped_parts = [re.escape(part) for part in parts] - regex_pattern = ".*".join(escaped_parts) - return regex_pattern + str1_esc = re.escape(str1) + str1_esc = re.sub(r"(%\\\([^)]+\\\)s|%s)", ".*", str1_esc) + return str1_esc def _create_lot(self, product, name): - return self.env["stock.production.lot"].create( + return self.env["stock.lot"].create( { "name": name, "product_id": product.id, @@ -234,7 +240,7 @@ def _receive_stock(self, location, product, lot, qty, picking_type=None): "product_id": product.id, "product_uom_id": product.uom_id.id, "lot_id": lot.id, - "qty_done": qty, + "quantity": qty, "location_id": self.supplier_location.id, "location_dest_id": location.id, "company_id": self.env.company.id, @@ -260,7 +266,7 @@ def _create_incoming_picking(self, location, product, lot, qty, picking_type=Non "product_id": product.id, "product_uom_id": product.uom_id.id, "lot_id": lot.id, - "qty_done": qty, + "quantity": qty, "location_id": self.supplier_location.id, "location_dest_id": location.id, "company_id": self.env.company.id, @@ -308,19 +314,17 @@ def _seed_flowable_location( def _create_inventory_adjustment(self, location, product, lot, qty): """Create and validate a simple inventory adjustment (1 lot).""" - inventory = self.env["stock.inventory"].create( - {"name": f"Adjust {product.name} at {location.name}"} - ) - inventory.action_start() - self.env["stock.inventory.line"].create( - { - "inventory_id": inventory.id, - "product_id": product.id, - "product_uom_id": product.uom_id.id, - "location_id": location.id, - "prod_lot_id": lot.id, - "product_qty": qty, - } + quant = ( + self.env["stock.quant"] + .with_context(inventory_mode=True) + .create( + { + "product_id": product.id, + "location_id": location.id, + "lot_id": lot.id, + "inventory_quantity": qty, + } + ) ) - inventory.action_validate() - return inventory + quant.action_apply_inventory() + return quant diff --git a/stock_location_flowable/tests/test_mrp_production.py b/stock_location_flowable/tests/test_mrp_production.py index 174510804..b95cfa10d 100644 --- a/stock_location_flowable/tests/test_mrp_production.py +++ b/stock_location_flowable/tests/test_mrp_production.py @@ -1,5 +1,6 @@ # Copyright NuoBiT Solutions - Frank Cespedes # Copyright 2026 NuoBiT Solutions - Eric Antones +# Copyright 2026 NuoBiT Solutions SL- Deniz Gallo # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import logging @@ -38,7 +39,7 @@ def test_block_new_production_flowable_location_by_outgoing_picking(self): # ASSERT msg_error = ( - "The location %s is blocked. Probably you need to review" + "The location %(location)s is blocked. Probably you need to review" " the pending manufacturing orders related to this location" ) msg_error = self.get_error_message_regex(msg_error) @@ -315,7 +316,8 @@ def test_flowable_mixing_with_custom_uom_rounding(self): product_o2 = self.env["product.product"].create( { "name": "Liquid O2", - "type": "product", + "type": "consu", + "is_storable": True, "uom_id": uom_litro_o2.id, "uom_po_id": uom_litro_o2.id, "tracking": "lot", @@ -334,7 +336,9 @@ def test_flowable_mixing_with_custom_uom_rounding(self): "flowable_uom_id": uom_litro_o2.id, "flowable_allowed_product_ids": [(4, product_o2.id)], "flowable_create_lots": True, - "flowable_sequence_id": self.env.ref("stock.sequence_tracking").id, + "flowable_sequence_id": self.env.ref( + "stock.sequence_production_lots" + ).id, } ) @@ -374,7 +378,7 @@ def test_flowable_mixing_with_custom_uom_rounding(self): "product_id": product_o2.id, "product_uom_id": uom_litro_o2.id, "lot_id": lot_new.id, - "qty_done": litres_2, + "quantity": litres_2, "location_id": self.supplier_location.id, "location_dest_id": location_cistern.id, "company_id": self.env.company.id, @@ -446,7 +450,8 @@ def test_flowable_mixing_multiple_cycles_fine_rounding(self): product_o2 = self.env["product.product"].create( { "name": "Liquid O2", - "type": "product", + "type": "consu", + "is_storable": True, "uom_id": uom_litro_o2.id, "uom_po_id": uom_litro_o2.id, "tracking": "lot", @@ -465,7 +470,9 @@ def test_flowable_mixing_multiple_cycles_fine_rounding(self): "flowable_uom_id": uom_litro_o2.id, "flowable_allowed_product_ids": [(4, product_o2.id)], "flowable_create_lots": True, - "flowable_sequence_id": self.env.ref("stock.sequence_tracking").id, + "flowable_sequence_id": self.env.ref( + "stock.sequence_production_lots" + ).id, } ) @@ -490,7 +497,7 @@ def test_flowable_mixing_multiple_cycles_fine_rounding(self): "product_id": product_o2.id, "product_uom_id": uom_litro_o2.id, "lot_id": lot.id, - "qty_done": litres, + "quantity": litres, "location_id": self.supplier_location.id, "location_dest_id": location_cistern.id, "company_id": self.env.company.id, @@ -800,7 +807,8 @@ def test_reservation_conflict_from_production_move(self): finished_product = self.env["product.product"].create( { "name": "Finished Product", - "type": "product", + "type": "consu", + "is_storable": True, "uom_id": self.env.ref("uom.product_uom_unit").id, "uom_po_id": self.env.ref("uom.product_uom_unit").id, } @@ -850,18 +858,16 @@ def test_reservation_conflict_from_production_move(self): "location_dest_id": self.location_flowable_3.location_id.id, } ) - # Populate raw and finished moves from the BoM - self.env["stock.move"].create(regular_mo._get_moves_raw_values()) - self.env["stock.move"].create(regular_mo._get_moves_finished_values()) regular_mo.action_confirm() regular_mo.action_assign() # Verify the regular MO reserved stock at the flowable location - raw_move = regular_mo.move_raw_ids - self.assertGreater( - raw_move.reserved_availability, - 0, - "Regular MO should have reserved stock from the flowable location", + raw_moves = regular_mo.move_raw_ids.filtered( + lambda m: m.product_id == self.product_flowable_1 + ) + self.assertTrue( + all(m.state == "assigned" for m in raw_moves), + "Regular MO raw moves should be fully assigned (reserved)", ) # ACT — receive new stock at the flowable location @@ -990,19 +996,18 @@ def test_flowable_blocking_with_pending_reservations_and_reception(self): self.assertEqual(sale_picking_2.state, "confirmed") # Inventory adjustment: +1000 L on lot X1 - inventory = self.env["stock.inventory"].create( - { - "name": "Adjust +1000L on X1", - "location_ids": [(4, self.location_flowable_4.id)], - "product_ids": [(4, self.product_flowable_1.id)], - } + existing_quant = self.env["stock.quant"].search( + [ + ("product_id", "=", self.product_flowable_1.id), + ("location_id", "=", self.location_flowable_4.id), + ("lot_id", "=", lot_x1.id), + ], + limit=1, ) - inventory.action_start() - inv_line = inventory.line_ids.filtered( - lambda l: l.location_id == self.location_flowable_4 + existing_quant.with_context(inventory_mode=True).write( + {"inventory_quantity": existing_quant.quantity + 1000} ) - inv_line[0].product_qty = inv_line[0].product_qty + 1000 - inventory.action_validate() + existing_quant.action_apply_inventory() self.assertEqual( self._get_positive_quantity( self.location_flowable_4, self.product_flowable_1 @@ -1036,10 +1041,10 @@ def test_flowable_blocking_with_pending_reservations_and_reception(self): "The following operations must be unreserved" " or completed first:\n\n" " - Liquid O2: 100.0 L (lot X1)" - " - %s (Delivery1)\n" + f" - {sale_picking_1.name} (Delivery1)\n" " - Liquid O2: 600.0 L (lot X1)" - " - %s (Delivery1)" - ) % (sale_picking_1.name, sale_picking_3.name) + f" - {sale_picking_3.name} (Delivery1)" + ) self.assertEqual(str(error.exception), expected_msg) def test_flowable_merge_succeeds_with_unreserved_operations(self): @@ -1047,8 +1052,7 @@ def test_flowable_merge_succeeds_with_unreserved_operations(self): Test that receiving stock at a flowable location succeeds when all sales have been unreserved before the reception. - _trigger_assign is bypassed for flowable receptions, so the - unreserved sales stay confirmed. The mixing MO fully reserves + The unreserved sales stay confirmed. The mixing MO fully reserves all stock and succeeds. PRE: - Flowable location 'O2 Tank 4' (capacity 15000 L), initially empty @@ -1099,19 +1103,18 @@ def test_flowable_merge_succeeds_with_unreserved_operations(self): self.assertEqual(sale_picking_2.state, "confirmed") # Inventory adjustment: +1000 L on lot X1 - inventory = self.env["stock.inventory"].create( - { - "name": "Adjust +1000L on X1", - "location_ids": [(4, self.location_flowable_4.id)], - "product_ids": [(4, self.product_flowable_1.id)], - } + existing_quant = self.env["stock.quant"].search( + [ + ("product_id", "=", self.product_flowable_1.id), + ("location_id", "=", self.location_flowable_4.id), + ("lot_id", "=", lot_x1.id), + ], + limit=1, ) - inventory.action_start() - inv_line = inventory.line_ids.filtered( - lambda l: l.location_id == self.location_flowable_4 + existing_quant.with_context(inventory_mode=True).write( + {"inventory_quantity": existing_quant.quantity + 1000} ) - inv_line[0].product_qty = inv_line[0].product_qty + 1000 - inventory.action_validate() + existing_quant.action_apply_inventory() self.assertEqual( self._get_positive_quantity( self.location_flowable_4, self.product_flowable_1 diff --git a/stock_location_flowable/tests/test_stock_location.py b/stock_location_flowable/tests/test_stock_location.py index 56b722fb4..65c1f899f 100644 --- a/stock_location_flowable/tests/test_stock_location.py +++ b/stock_location_flowable/tests/test_stock_location.py @@ -1,5 +1,6 @@ # Copyright NuoBiT Solutions - Frank Cespedes # Copyright 2026 NuoBiT Solutions - Eric Antones +# Copyright 2026 NuoBiT Solutions SL- Deniz Gallo # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import logging @@ -20,21 +21,16 @@ def setUpClass(cls): cls.product_flowable_1 = cls.env["product.product"].create( { "name": "Liquid CO2", - "type": "product", + "type": "consu", + "is_storable": True, "uom_id": cls.uom_litre.id, "uom_po_id": cls.uom_litre.id, } ) - cls.warehouse_bcn = cls.env["stock.warehouse"].create( - { - "name": "Warehouse Barcelona", - "code": "BCN", - } - ) cls.location_flowable_bcn_1 = cls.env["stock.location"].create( { "name": "O2 Tank 5", - "location_id": cls.warehouse_bcn.lot_stock_id.id, + "location_id": cls.env.ref("stock.stock_location_locations_partner").id, } ) @@ -206,7 +202,8 @@ def test_adding_product_with_incompatible_uom(self): product_flowable_2 = self.env["product.product"].create( { "name": "CO2 Cylinder", - "type": "product", + "type": "consu", + "is_storable": True, "uom_id": self.uom_unit.id, "uom_po_id": self.uom_unit.id, "tracking": "lot", @@ -226,7 +223,7 @@ def test_adding_product_with_incompatible_uom(self): # ASSERT msg_error = ( - "The product %s is measured in %s. You can only assign" + "The product %(product)s is measured in %(uom)s. You can only assign" " products that have the allowed unit of measure" ) msg_error = self.get_error_message_regex(msg_error) @@ -244,14 +241,15 @@ def test_changing_to_incompatible_uom_id(self): - 'flowable_allowed_product_ids' contains products with the current 'flowable_uom_id' ACT: - Attempt to change 'flowable_uom_id' to a different unit of measure - POST: - ValidationError is raised stating that only products with the allowed unit - of measure can be assigned + POST: - ValidationError is raised stating that only products + with the allowed unit of measure can be assigned """ # ARRANGE product_flowable_2 = self.env["product.product"].create( { "name": "CO2 Cylinder", - "type": "product", + "type": "consu", + "is_storable": True, "uom_id": self.uom_unit.id, "uom_po_id": self.uom_unit.id, "tracking": "lot", @@ -277,7 +275,7 @@ def test_changing_to_incompatible_uom_id(self): # ASSERT msg_error = ( - "The product %s is measured in %s. You can only assign" + "The product %(product)s is measured in %(uom)s. You can only assign" " products that have the allowed unit of measure" ) msg_error = self.get_error_message_regex(msg_error) @@ -395,7 +393,7 @@ def test_flowable_capacity_occupied(self): production.button_mark_done() # ASSERT - self.location_flowable_1.invalidate_cache() + self.location_flowable_1.invalidate_recordset() self.assertGreater(self.location_flowable_1.flowable_capacity_occupied, 0) def test_flowable_percentage_occupied(self): @@ -518,7 +516,7 @@ def test_cannot_remove_stored_product_from_allowed(self): ) msg_error = ( - "You cannot remove a product that is currently" " stored in this location." + "You cannot remove a product that is currently stored in this location." ) msg_error = self.get_error_message_regex(msg_error) self.assertRegex(error.exception.args[0], msg_error) @@ -592,7 +590,8 @@ def test_change_uom_with_existing_stock(self): product_unit = self.env["product.product"].create( { "name": "Ar Cylinder", - "type": "product", + "type": "consu", + "is_storable": True, "uom_id": self.uom_unit.id, "uom_po_id": self.uom_unit.id, "tracking": "lot", @@ -669,7 +668,7 @@ def test_reduce_capacity_below_occupied_via_config(self): self._seed_flowable_location(self.location_flowable_1, product, lot, 100) # Verify occupied amount is set - self.location_flowable_1.invalidate_cache() + self.location_flowable_1.invalidate_recordset() self.assertGreater(self.location_flowable_1.flowable_capacity_occupied, 0) # ACT & ASSERT @@ -695,7 +694,8 @@ def test_convert_location_with_incompatible_uom_stock(self): product_litre = self.env["product.product"].create( { "name": "Liquid Ar", - "type": "product", + "type": "consu", + "is_storable": True, "uom_id": self.uom_litre.id, "uom_po_id": self.uom_litre.id, "tracking": "lot", @@ -738,5 +738,5 @@ def test_capacity_occupied_zero_for_non_flowable_location(self): self._create_inventory_adjustment(self.location_1, product, lot, 200) # ACT & ASSERT - self.location_1.invalidate_cache() + self.location_1.invalidate_recordset() self.assertEqual(self.location_1.flowable_capacity_occupied, 0) diff --git a/stock_location_flowable/tests/test_stock_move.py b/stock_location_flowable/tests/test_stock_move.py index ad11e0f95..ade90479a 100644 --- a/stock_location_flowable/tests/test_stock_move.py +++ b/stock_location_flowable/tests/test_stock_move.py @@ -3,7 +3,7 @@ import logging -from odoo.exceptions import UserError +from odoo.exceptions import ValidationError from .test_common import TestCommon @@ -18,15 +18,16 @@ def setUpClass(cls): def test_modify_in_progress_flowable_move_raises_error(self): """ Test that cancelling a flowable production with a picking triggers - the stock.move write guard, which prevents state changes on raw - moves of an in-progress mixing. + a ValidationError from the state constraint, preventing the + cancellation of an in-progress mixing. - When a user clicks "Cancel" on the MO, the cancel flow attempts to - change the raw moves' state, and the write override blocks it. + When a user clicks "Cancel" on the MO, the cancel flow changes + the raw moves' state, and the computed state field triggers + the @api.constrains check which blocks it. PRE: - A flowable MO in to_close state with a picking ACT: - Cancel the production (user action) - POST: - UserError is raised about mixing in progress + POST: - ValidationError is raised about mixing in progress """ # ARRANGE self.picking_type_mrp_operation_1.flowable_operation = True @@ -37,11 +38,11 @@ def test_modify_in_progress_flowable_move_raises_error(self): self.assertEqual(production.state, "to_close") # ACT & ASSERT - with self.assertRaises(UserError) as error: + with self.assertRaises(ValidationError) as error: production.action_cancel() msg_error = ( - "You cannot modify a production with a picking associated." + "You cannot cancel a production with a picking associated." " The mixing is in progress." ) msg_error = self.get_error_message_regex(msg_error) diff --git a/stock_location_flowable/tests/test_stock_move_line.py b/stock_location_flowable/tests/test_stock_move_line.py index 4e1944e93..6e1a1a09c 100644 --- a/stock_location_flowable/tests/test_stock_move_line.py +++ b/stock_location_flowable/tests/test_stock_move_line.py @@ -1,4 +1,5 @@ # Copyright 2026 NuoBiT Solutions - Eric Antones +# Copyright 2026 NuoBiT Solutions SL - Deniz Gallo # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import logging @@ -36,7 +37,7 @@ def test_blocked_location_rejects_unrelated_production_done(self): self.outgoing_picking.button_validate() msg_error = ( - "The location %s is blocked. Probably you need to review" + "The location %(location)s is blocked. Probably you need to review" " the pending manufacturing orders related to this location" ) msg_error = self.get_error_message_regex(msg_error) @@ -69,7 +70,7 @@ def test_blocked_location_rejects_incoming_reception(self): second_picking.button_validate() msg_error = ( - "The location %s is blocked. Probably you need to review" + "The location %(location)s is blocked. Probably you need to review" " the pending manufacturing orders related to this location" ) msg_error = self.get_error_message_regex(msg_error) @@ -102,7 +103,7 @@ def test_blocked_location_rejects_internal_transfer(self): "product_id": self.product_flowable_1.id, "product_uom_id": self.product_flowable_1.uom_id.id, "lot_id": lot_2.id, - "qty_done": 5, + "quantity": 5, "location_id": self.location_flowable_1.id, "location_dest_id": self.location_1.id, "company_id": self.env.company.id, @@ -118,7 +119,7 @@ def test_blocked_location_rejects_internal_transfer(self): transfer_picking.button_validate() msg_error = ( - "The location %s is blocked. Probably you need to review" + "The location %(location)s is blocked. Probably you need to review" " the pending manufacturing orders related to this location" ) msg_error = self.get_error_message_regex(msg_error) diff --git a/stock_location_flowable/tests/test_stock_picking.py b/stock_location_flowable/tests/test_stock_picking.py index c47b58381..f1cf3d6bf 100644 --- a/stock_location_flowable/tests/test_stock_picking.py +++ b/stock_location_flowable/tests/test_stock_picking.py @@ -1,5 +1,6 @@ # Copyright NuoBiT Solutions - Frank Cespedes # Copyright 2026 NuoBiT Solutions - Eric Antones +# Copyright 2026 NuoBiT Solutions SL- Deniz Gallo # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import logging @@ -97,7 +98,7 @@ def test_receiving_one_product_in_flowable_location_incoming_picking(self): "product_id": self.product_flowable_1.id, "product_uom_id": self.product_flowable_1.uom_id.id, "lot_id": lot_1.id, - "qty_done": 5, + "quantity": 5, "location_id": self.incoming_picking.location_id.id, "location_dest_id": self.incoming_picking.location_dest_id.id, } @@ -109,7 +110,7 @@ def test_receiving_one_product_in_flowable_location_incoming_picking(self): "product_id": self.product_flowable_2.id, "product_uom_id": self.product_flowable_2.uom_id.id, "lot_id": lot_ch4.id, - "qty_done": 10, + "quantity": 10, "location_id": self.incoming_picking.location_id.id, "location_dest_id": self.incoming_picking.location_dest_id.id, } @@ -122,7 +123,8 @@ def test_receiving_one_product_in_flowable_location_incoming_picking(self): # ASSERT msg_error = ( "Cannot receive multiple product/lot combinations" - " (%s) at flowable location '%s' in the same" + " (%(details)s) at flowable location '%(location)s'" + " in the same" " receipt. Each combination generates a separate" " mixing order and the location is blocked after" " the first one. Create a backorder to receive" @@ -162,7 +164,7 @@ def test_same_product_different_lots_same_location_rejected(self): "product_id": self.product_flowable_1.id, "product_uom_id": self.product_flowable_1.uom_id.id, "lot_id": lot.id, - "qty_done": qty, + "quantity": qty, "location_id": self.supplier_location.id, "location_dest_id": self.location_flowable_1.id, "company_id": self.env.company.id, @@ -175,7 +177,8 @@ def test_same_product_different_lots_same_location_rejected(self): msg_error = ( "Cannot receive multiple product/lot combinations" - " (%s) at flowable location '%s' in the same" + " (%(details)s) at flowable location '%(location)s'" + " in the same" " receipt. Each combination generates a separate" " mixing order and the location is blocked after" " the first one. Create a backorder to receive" @@ -191,7 +194,8 @@ def test_only_allowed_product_in_incoming_picking(self): product_bolts = self.env["product.product"].create( { "name": "Steel Bolts", - "type": "product", + "type": "consu", + "is_storable": True, "uom_id": self.env.ref("uom.product_uom_unit").id, "uom_po_id": self.env.ref("uom.product_uom_unit").id, "tracking": "lot", @@ -220,7 +224,7 @@ def test_only_allowed_product_in_incoming_picking(self): "product_id": product_bolts.id, "product_uom_id": product_bolts.uom_id.id, "lot_id": lot_bolts.id, - "qty_done": 5, + "quantity": 5, "location_id": self.incoming_picking.location_id.id, "location_dest_id": self.incoming_picking.location_dest_id.id, } @@ -231,7 +235,9 @@ def test_only_allowed_product_in_incoming_picking(self): self.incoming_picking.button_validate() # ASSERT - msg_error = "Product %s not allowed in flowable location %s" + msg_error = ( + "Product %(product)s not allowed" " in flowable location %(location)s" + ) msg_error = self.get_error_message_regex(msg_error) self.assertRegex(error.exception.args[0], msg_error) @@ -242,7 +248,8 @@ def test_different_uom_allowed_product_in_incoming_picking(self): product_he = self.env["product.product"].create( { "name": "Liquid He", - "type": "product", + "type": "consu", + "is_storable": True, "uom_id": self.env.ref("uom.product_uom_litre").id, "uom_po_id": self.env.ref("uom.product_uom_litre").id, "tracking": "lot", @@ -280,7 +287,7 @@ def test_different_uom_allowed_product_in_incoming_picking(self): "product_id": product_he.id, "product_uom_id": product_he.uom_id.id, "lot_id": lot_he.id, - "qty_done": 5, + "quantity": 5, "location_id": self.incoming_picking.location_id.id, "location_dest_id": self.incoming_picking.location_dest_id.id, } @@ -292,8 +299,9 @@ def test_different_uom_allowed_product_in_incoming_picking(self): # ASSERT msg_error = ( - "The allowed products %s cannot have different Unit of Measure" - " than flowable location %s" + "The allowed products %(product)s cannot have" + " different Unit of Measure than" + " flowable location %(location)s" ) msg_error = self.get_error_message_regex(msg_error) self.assertRegex(error.exception.args[0], msg_error) @@ -322,7 +330,7 @@ def test_not_found_manufacturing_picking_type_incoming_picking(self): "product_id": self.product_flowable_1.id, "product_uom_id": self.product_flowable_1.uom_id.id, "lot_id": lot_1.id, - "qty_done": 10, + "quantity": 10, "location_id": self.incoming_picking.location_id.id, "location_dest_id": self.incoming_picking.location_dest_id.id, } @@ -335,7 +343,10 @@ def test_not_found_manufacturing_picking_type_incoming_picking(self): self.incoming_picking.button_validate() # ASSERT - msg_error = "Not found flowable manufacturing picking type in warehouse %s" + msg_error = ( + "Not found flowable manufacturing picking type" + " in warehouse %(warehouse)s" + ) msg_error = self.get_error_message_regex(msg_error) self.assertRegex(error.exception.args[0], msg_error) @@ -364,7 +375,7 @@ def test_more_than_one_manufacturing_picking_type_incoming_picking(self): "UPDATE stock_picking_type SET flowable_operation = TRUE WHERE id = %s", (picking_type_mrp_operation_2.id,), ) - picking_type_mrp_operation_2.invalidate_cache() + picking_type_mrp_operation_2.invalidate_recordset() lot_1 = self._create_lot(self.product_flowable_1, "TEST-DUP-LOT") @@ -374,7 +385,7 @@ def test_more_than_one_manufacturing_picking_type_incoming_picking(self): "product_id": self.product_flowable_1.id, "product_uom_id": self.product_flowable_1.uom_id.id, "lot_id": lot_1.id, - "qty_done": 10, + "quantity": 10, "location_id": self.supplier_location.id, "location_dest_id": self.incoming_picking.location_dest_id.id, "company_id": self.env.company.id, @@ -385,7 +396,10 @@ def test_more_than_one_manufacturing_picking_type_incoming_picking(self): with self.assertRaises(UserError) as error: self.incoming_picking.button_validate() - msg_error = "More than one flowable manufacturing picking type in warehouse %s" + msg_error = ( + "More than one flowable manufacturing picking type" + " in warehouse %(warehouse)s" + ) msg_error = self.get_error_message_regex(msg_error) self.assertRegex(error.exception.args[0], msg_error) @@ -401,7 +415,7 @@ def test_successfull_picking_type_incoming_picking(self): "product_id": self.product_flowable_1.id, "product_uom_id": self.product_flowable_1.uom_id.id, "lot_id": lot_1.id, - "qty_done": 10, + "quantity": 10, "location_id": self.supplier_location.id, "location_dest_id": self.incoming_picking.location_dest_id.id, "company_id": self.env.company.id, @@ -433,7 +447,7 @@ def test_action_view_mrp_production_single(self): "product_id": self.product_flowable_1.id, "product_uom_id": self.product_flowable_1.uom_id.id, "lot_id": lot_1.id, - "qty_done": 10, + "quantity": 10, "location_id": self.supplier_location.id, "location_dest_id": self.incoming_picking.location_dest_id.id, "company_id": self.env.company.id, @@ -471,7 +485,7 @@ def test_action_view_mrp_production_multiple(self): "product_id": self.product_flowable_1.id, "product_uom_id": self.product_flowable_1.uom_id.id, "lot_id": lot_1.id, - "qty_done": 10, + "quantity": 10, "location_id": self.supplier_location.id, "location_dest_id": self.incoming_picking.location_dest_id.id, "company_id": self.env.company.id, @@ -523,7 +537,7 @@ def test_mrp_operation_type_without_sequence_raises_error(self): "product_id": self.product_flowable_1.id, "product_uom_id": self.product_flowable_1.uom_id.id, "lot_id": lot_1.id, - "qty_done": 10, + "quantity": 10, "location_id": self.supplier_location.id, "location_dest_id": self.incoming_picking.location_dest_id.id, "company_id": self.env.company.id, @@ -534,7 +548,10 @@ def test_mrp_operation_type_without_sequence_raises_error(self): with self.assertRaises(UserError) as error: self.incoming_picking.button_validate() - msg_error = "Not found sequence in flowable manufacturing picking type %s" + msg_error = ( + "Not found sequence in flowable manufacturing" + " picking type %(picking_type)s" + ) msg_error = self.get_error_message_regex(msg_error) self.assertRegex(error.exception.args[0], msg_error) @@ -555,7 +572,8 @@ def test_non_lot_tracked_product_at_flowable_location(self): product_nolot = self.env["product.product"].create( { "name": "Liquid H2", - "type": "product", + "type": "consu", + "is_storable": True, "uom_id": self.env.ref("uom.product_uom_litre").id, "uom_po_id": self.env.ref("uom.product_uom_litre").id, "tracking": "lot", @@ -564,7 +582,8 @@ def test_non_lot_tracked_product_at_flowable_location(self): self.location_flowable_1.write( {"flowable_allowed_product_ids": [(4, product_nolot.id)]} ) - # Change tracking after adding to allowed products (bypasses location constraint) + # Change tracking after adding to allowed products + # (bypasses location constraint) product_nolot.tracking = "none" lot = self._create_lot(product_nolot, "TEST-NOLOT") @@ -576,7 +595,7 @@ def test_non_lot_tracked_product_at_flowable_location(self): with self.assertRaises(UserError) as error: picking.button_validate() - msg_error = "Product %s must be tracked by lot" + msg_error = "Product %(product)s must be tracked by lot" msg_error = self.get_error_message_regex(msg_error) self.assertRegex(error.exception.args[0], msg_error) @@ -666,7 +685,7 @@ def test_second_reception_to_blocked_location_rejected(self): self.assertTrue(self.location_flowable_1.flowable_blocked) # ACT & ASSERT - with self.assertRaises(Exception): + with self.assertRaises(UserError): second_picking.button_validate() def test_reception_after_mo_completed_succeeds(self): @@ -906,7 +925,7 @@ def test_multiple_lines_same_product_aggregated_into_one_mo(self): "product_id": self.product_flowable_1.id, "product_uom_id": self.product_flowable_1.uom_id.id, "lot_id": lot_b.id, - "qty_done": qty, + "quantity": qty, "location_id": self.supplier_location.id, "location_dest_id": self.location_flowable_1.id, "company_id": self.env.company.id, @@ -940,8 +959,8 @@ def test_multiple_lines_same_product_aggregated_into_one_mo(self): lot_b_line = raw_move_lines.filtered(lambda ml: ml.lot_id == lot_b) self.assertEqual(len(lot_a_line), 1) self.assertEqual(len(lot_b_line), 1) - self.assertEqual(lot_a_line.qty_done, 100) - self.assertEqual(lot_b_line.qty_done, total_received) + self.assertEqual(lot_a_line.quantity, 100) + self.assertEqual(lot_b_line.quantity, total_received) # The location should be blocked by that MO self.assertTrue(self.location_flowable_1.flowable_blocked) @@ -1026,7 +1045,7 @@ def test_backorder_remaining_lines_still_rejected(self): "product_id": self.product_flowable_1.id, "product_uom_id": self.product_flowable_1.uom_id.id, "lot_id": lot.id, - "qty_done": qty, + "quantity": qty, "location_id": self.supplier_location.id, "location_dest_id": self.location_flowable_1.id, "company_id": self.env.company.id, @@ -1039,8 +1058,12 @@ def test_backorder_remaining_lines_still_rejected(self): msg_error = ( "Cannot receive multiple product/lot combinations" - " (%s) at flowable location '%s' in the same" - " receipt." + " (%(details)s) at flowable location '%(location)s'" + " in the same" + " receipt. Each combination generates a separate" + " mixing order and the location is blocked after" + " the first one. Create a backorder to receive" + " them in separate steps." ) msg_error = self.get_error_message_regex(msg_error) self.assertRegex(error.exception.args[0], msg_error) @@ -1078,7 +1101,7 @@ def test_backorder_validation_while_location_blocked(self): self.assertTrue(self.location_flowable_1.flowable_blocked) # ACT & ASSERT — second reception rejected while location is blocked - with self.assertRaises(Exception): + with self.assertRaises(UserError): picking_2.button_validate() def test_mixed_flowable_and_non_flowable_lines(self): @@ -1113,7 +1136,7 @@ def test_mixed_flowable_and_non_flowable_lines(self): "product_id": self.product_flowable_1.id, "product_uom_id": self.product_flowable_1.uom_id.id, "lot_id": lot_flow.id, - "qty_done": 50, + "quantity": 50, "location_id": self.supplier_location.id, "location_dest_id": self.location_flowable_1.id, "company_id": self.env.company.id, @@ -1126,7 +1149,7 @@ def test_mixed_flowable_and_non_flowable_lines(self): "product_id": self.product_flowable_1.id, "product_uom_id": self.product_flowable_1.uom_id.id, "lot_id": lot_nonflow.id, - "qty_done": 30, + "quantity": 30, "location_id": self.supplier_location.id, "location_dest_id": self.location_1.id, "company_id": self.env.company.id, @@ -1181,7 +1204,7 @@ def test_same_product_different_lots_different_locations_succeeds(self): "product_id": self.product_flowable_1.id, "product_uom_id": self.product_flowable_1.uom_id.id, "lot_id": lot_a.id, - "qty_done": 50, + "quantity": 50, "location_id": self.supplier_location.id, "location_dest_id": self.location_flowable_1.id, "company_id": self.env.company.id, @@ -1194,7 +1217,7 @@ def test_same_product_different_lots_different_locations_succeeds(self): "product_id": self.product_flowable_1.id, "product_uom_id": self.product_flowable_1.uom_id.id, "lot_id": lot_b.id, - "qty_done": 50, + "quantity": 50, "location_id": self.supplier_location.id, "location_dest_id": self.location_flowable_2.id, "company_id": self.env.company.id, @@ -1214,11 +1237,11 @@ def test_same_product_different_lots_different_locations_succeeds(self): def test_zero_qty_done_flowable_lines_ignored(self): """ - Test that move lines with qty_done=0 at a flowable location are + Test that move lines with quantity=0 at a flowable location are ignored and don't create MOs or trigger conflicts. PRE: - A picking with 2 lines to a flowable location: one with - qty_done=50 and another with qty_done=0 + quantity=50 and another with quantity=0 ACT: - Validate the picking POST: - Only 1 MO is created (for the non-zero line) - No pre-check error about multiple combinations @@ -1242,7 +1265,7 @@ def test_zero_qty_done_flowable_lines_ignored(self): "product_id": self.product_flowable_1.id, "product_uom_id": self.product_flowable_1.uom_id.id, "lot_id": lot_a.id, - "qty_done": 50, + "quantity": 50, "location_id": self.supplier_location.id, "location_dest_id": self.location_flowable_1.id, "company_id": self.env.company.id, @@ -1254,7 +1277,7 @@ def test_zero_qty_done_flowable_lines_ignored(self): "product_id": self.product_flowable_1.id, "product_uom_id": self.product_flowable_1.uom_id.id, "lot_id": lot_b.id, - "qty_done": 0, + "quantity": 0, "location_id": self.supplier_location.id, "location_dest_id": self.location_flowable_1.id, "company_id": self.env.company.id, diff --git a/stock_location_flowable/tests/test_stock_picking_type.py b/stock_location_flowable/tests/test_stock_picking_type.py index b9ffcfbd0..881b2b74e 100644 --- a/stock_location_flowable/tests/test_stock_picking_type.py +++ b/stock_location_flowable/tests/test_stock_picking_type.py @@ -32,7 +32,10 @@ def test_unique_flowable_operation_picking_type(self): ) # ASSERT - msg_error = "Only one picking type can be flowable in a warehouse %s." + msg_error = ( + "Only one picking type can be flowable" + " in a warehouse %(warehouse_name)s." + ) msg_error = self.get_error_message_regex(msg_error) self.assertRegex(error.exception.args[0], msg_error) diff --git a/stock_location_flowable/tests/test_stock_quant.py b/stock_location_flowable/tests/test_stock_quant.py index 14ebcc52d..242118016 100644 --- a/stock_location_flowable/tests/test_stock_quant.py +++ b/stock_location_flowable/tests/test_stock_quant.py @@ -1,4 +1,5 @@ # Copyright 2026 NuoBiT Solutions - Eric Antones +# Copyright 2026 NuoBiT Solutions SL - Deniz Gallo # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import logging @@ -36,26 +37,16 @@ def test_unique_lot_constraint_at_flowable_location(self): # ACT — second lot via inventory adjustment lot_2 = self._create_lot(self.product_flowable_1, "QUANT-LOT-2") - inventory_2 = self.env["stock.inventory"].create( - { - "name": "Add second lot", - } - ) - inventory_2.action_start() - self.env["stock.inventory.line"].create( - { - "inventory_id": inventory_2.id, - "product_id": self.product_flowable_1.id, - "product_uom_id": self.product_flowable_1.uom_id.id, - "location_id": self.location_flowable_1.id, - "prod_lot_id": lot_2.id, - "product_qty": 50, - } - ) - # ASSERT with self.assertRaises(ValidationError) as error: - inventory_2.action_validate() + self.env["stock.quant"].with_context(inventory_mode=True).create( + { + "product_id": self.product_flowable_1.id, + "location_id": self.location_flowable_1.id, + "lot_id": lot_2.id, + "inventory_quantity": 50, + } + ).action_apply_inventory() msg_error = "You cannot have more than one lot in the same location." msg_error = self.get_error_message_regex(msg_error) diff --git a/stock_location_flowable/tests/test_stock_return_picking.py b/stock_location_flowable/tests/test_stock_return_picking.py index 85993bb80..6a56c76c6 100644 --- a/stock_location_flowable/tests/test_stock_return_picking.py +++ b/stock_location_flowable/tests/test_stock_return_picking.py @@ -1,4 +1,5 @@ # Copyright 2026 NuoBiT Solutions - Eric Antones +# Copyright 2025 NuoBiT Solutions SL - Deniz Gallo # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import logging @@ -42,14 +43,12 @@ def test_return_from_flowable_location_raises_error(self): .create( { "picking_id": picking.id, - "location_id": self.supplier_location.id, } ) ) - return_wizard._onchange_picking_id() with self.assertRaises(UserError) as error: - return_wizard._create_returns() + return_wizard._create_return() # ASSERT msg_error = ( @@ -93,7 +92,7 @@ def test_return_from_non_flowable_location_succeeds(self): picking.action_assign() move.move_line_ids.write( { - "qty_done": 50, + "quantity": 50, "lot_id": lot.id, } ) @@ -109,17 +108,15 @@ def test_return_from_non_flowable_location_succeeds(self): .create( { "picking_id": picking.id, - "location_id": self.supplier_location.id, } ) ) - return_wizard._onchange_picking_id() - new_picking_id, pick_type_id = return_wizard._create_returns() + return_wizard.product_return_moves.quantity = 50 + new_picking = return_wizard._create_return() # ASSERT - self.assertTrue(new_picking_id) - return_picking = self.env["stock.picking"].browse(new_picking_id) - self.assertEqual(return_picking.state, "assigned") + self.assertTrue(new_picking) + self.assertEqual(new_picking.state, "assigned") def test_return_from_mixed_flowable_non_flowable_picking(self): """ @@ -179,7 +176,7 @@ def test_return_from_mixed_flowable_non_flowable_picking(self): ]: ml = move.move_line_ids if ml: - ml.write({"lot_id": lot.id, "qty_done": qty}) + ml.write({"lot_id": lot.id, "quantity": qty}) else: self.env["stock.move.line"].create( { @@ -188,7 +185,7 @@ def test_return_from_mixed_flowable_non_flowable_picking(self): "product_id": self.product_flowable_1.id, "product_uom_id": self.product_flowable_1.uom_id.id, "lot_id": lot.id, - "qty_done": qty, + "quantity": qty, "location_id": self.supplier_location.id, "location_dest_id": move.location_dest_id.id, "company_id": self.env.company.id, @@ -212,15 +209,13 @@ def test_return_from_mixed_flowable_non_flowable_picking(self): .create( { "picking_id": picking.id, - "location_id": self.supplier_location.id, } ) ) - return_wizard._onchange_picking_id() # ASSERT — error mentions the flowable product with self.assertRaises(UserError) as error: - return_wizard._create_returns() + return_wizard._create_return() msg_error = ( "You cannot return the following products because" diff --git a/stock_location_flowable/views/mrp_production_views.xml b/stock_location_flowable/views/mrp_production_views.xml index cfd170101..7de430110 100644 --- a/stock_location_flowable/views/mrp_production_views.xml +++ b/stock_location_flowable/views/mrp_production_views.xml @@ -1,5 +1,6 @@ @@ -11,22 +12,19 @@ - {'readonly': [('production_blocked', '=', True)]} + production_blocked - + - - {'invisible': [('production_flowable', '=', True)]} + + production_flowable - - {'invisible': [('production_flowable', '=', True)]} + + production_flowable diff --git a/stock_location_flowable/views/stock_location_views.xml b/stock_location_flowable/views/stock_location_views.xml index 59c68dea5..19365216b 100644 --- a/stock_location_flowable/views/stock_location_views.xml +++ b/stock_location_flowable/views/stock_location_views.xml @@ -1,5 +1,6 @@ @@ -11,7 +12,7 @@ @@ -24,7 +25,7 @@ icon="fa-wrench" string="Manufacturing Order" type="object" - attrs="{'invisible': [('flowable_production_id', '=', False)]}" + invisible="not flowable_production_id" groups="stock.group_stock_user" /> @@ -36,34 +37,37 @@ - stock.location.tree.inherit + stock.location.list.inherit stock.location @@ -97,17 +102,17 @@ string=" " name="flowable_blocked_popover" widget="stock_rescheduling_popover" - attrs="{'invisible': [('flowable_blocked', '=', False)]}" + invisible="not flowable_blocked" /> - + diff --git a/stock_location_flowable/views/stock_move_views.xml b/stock_location_flowable/views/stock_move_views.xml index 3697a362b..591205bec 100644 --- a/stock_location_flowable/views/stock_move_views.xml +++ b/stock_location_flowable/views/stock_move_views.xml @@ -1,9 +1,10 @@ - stock.move.line.operations.tree.inherit + stock.move.line.operations.list.inherit stock.move.line @@ -11,9 +12,7 @@ - {'readonly': [('raw_production_blocked', '=', True)]} + raw_production_blocked diff --git a/stock_location_flowable/views/stock_picking_type_views.xml b/stock_location_flowable/views/stock_picking_type_views.xml index cbed9ac1e..c46649607 100644 --- a/stock_location_flowable/views/stock_picking_type_views.xml +++ b/stock_location_flowable/views/stock_picking_type_views.xml @@ -1,5 +1,6 @@ @@ -7,10 +8,7 @@ - + diff --git a/stock_location_flowable/views/stock_picking_views.xml b/stock_location_flowable/views/stock_picking_views.xml index 7e236c79f..43894b8f5 100644 --- a/stock_location_flowable/views/stock_picking_views.xml +++ b/stock_location_flowable/views/stock_picking_views.xml @@ -1,5 +1,6 @@ @@ -14,7 +15,7 @@ icon="fa-wrench" string="Manufacturing Orders" type="object" - attrs="{'invisible': [('flowable_production_ids', '=', [])]}" + invisible="not flowable_production_ids" groups="stock.group_stock_user" /> From d22e4718ebd45632fe8848d28d397362c4364354 Mon Sep 17 00:00:00 2001 From: ??? Date: Tue, 17 Mar 2026 16:05:46 +0100 Subject: [PATCH 31/31] [DO NOT MERGE] test-requirements.txt --- test-requirements.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 test-requirements.txt diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 000000000..179d3c10b --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1 @@ +odoo-addon-uom_rounding_coherence@git+https://github.com/nuobit/odoo-addons.git@refs/pull/859/head#subdirectory=uom_rounding_coherence