From ee21018d8f2aa225c587ed04a69f080288fec6f1 Mon Sep 17 00:00:00 2001 From: danal-odoo Date: Tue, 9 Dec 2025 10:42:30 +0530 Subject: [PATCH] [ADD] sale_order_zero_stock_blockage: add manager approval on insufficient stock Currently, a sales user can confirm a Quotation even if it contains no products or if the products have insufficient stock compared to the order quantity. This leads to the creation of invalid Sales Orders, which negatively impacts inventory planning and invoicing. This module introduces a validation to the Sales Order confirmation process to prevent these issues. The following checks are added: 1. Prevent confirmation if the order has no lines. 2. Prevent confirmation if the product stock is insufficient. To bypass the insufficient stock restriction, a user with the 'Sales Manager' group must enable the new 'Approval' (zero_stock_approval) field on the order. Technical details: - Model `sale.order`: Added `zero_stock_approval` Boolean field. - Method `fields_get`: Overridden to set `zero_stock_approval` as readonly for users who are not in the `sales_team.group_sale_manager` group. - Method `action_confirm`: Added logic to raise a `UserError` if: - The order has no lines. - A product's `qty_available` is less than `product_uom_qty` (for goods) and the `zero_stock_approval` is not enabled. - added TestCases. Task-5382939 --- sale_order_zero_stock_blockage/__init__.py | 1 + .../__manifest__.py | 18 ++++++ .../models/__init__.py | 3 + .../models/sale_order.py | 39 ++++++++++++ .../tests/__init__.py | 3 + .../tests/test_sale_blockage.py | 59 +++++++++++++++++++ .../views/sale_order_view.xml | 13 ++++ 7 files changed, 136 insertions(+) create mode 100644 sale_order_zero_stock_blockage/__init__.py create mode 100644 sale_order_zero_stock_blockage/__manifest__.py create mode 100644 sale_order_zero_stock_blockage/models/__init__.py create mode 100644 sale_order_zero_stock_blockage/models/sale_order.py create mode 100644 sale_order_zero_stock_blockage/tests/__init__.py create mode 100644 sale_order_zero_stock_blockage/tests/test_sale_blockage.py create mode 100644 sale_order_zero_stock_blockage/views/sale_order_view.xml diff --git a/sale_order_zero_stock_blockage/__init__.py b/sale_order_zero_stock_blockage/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/sale_order_zero_stock_blockage/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/sale_order_zero_stock_blockage/__manifest__.py b/sale_order_zero_stock_blockage/__manifest__.py new file mode 100644 index 00000000000..8d8cac26216 --- /dev/null +++ b/sale_order_zero_stock_blockage/__manifest__.py @@ -0,0 +1,18 @@ +{ + "name": "Sale Order Zero Stock Blockage", + "description": """ + Zero Stock Blockage is module to prevent order to confirm if product is out of stock. + + But if manager wants then he can aprove that order. + """, + "version": "1.0", + "depends": ['sale_management', 'stock'], + "author": "danal", + "category": "Category", + "license": "LGPL-3", + "data": [ + "views/sale_order_view.xml", + ], + "installable": True, + 'application': False, +} diff --git a/sale_order_zero_stock_blockage/models/__init__.py b/sale_order_zero_stock_blockage/models/__init__.py new file mode 100644 index 00000000000..83d45ea62a4 --- /dev/null +++ b/sale_order_zero_stock_blockage/models/__init__.py @@ -0,0 +1,3 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import sale_order diff --git a/sale_order_zero_stock_blockage/models/sale_order.py b/sale_order_zero_stock_blockage/models/sale_order.py new file mode 100644 index 00000000000..e3ec844d1b8 --- /dev/null +++ b/sale_order_zero_stock_blockage/models/sale_order.py @@ -0,0 +1,39 @@ +from odoo.exceptions import UserError + +from odoo import fields, models, api + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + zero_stock_approval = fields.Boolean( + string="Approval", + help="Order Approval by manager,\nif order has insufficient stock then this approval is required by manager.", + copy=False, + ) + + @api.model + def fields_get(self, allfields=None, attributes=None): + res = super().fields_get(allfields, attributes) + if not self.env.user.has_group("sales_team.group_sale_manager"): + if "zero_stock_approval" in res: + res["zero_stock_approval"]["readonly"] = True + return res + + def action_confirm(self): + for record in self: + if not record.order_line: + raise UserError("You cannot confirm a Quotation without any products.") + if record.zero_stock_approval: + return super().action_confirm() + for line in record.order_line: + if ( + line.product_id.qty_available < line.product_uom_qty + and line.product_id.type == "consu" + and not record.zero_stock_approval + and not self.env.user.has_group("sales_team.group_sale_manager") + ): + raise UserError( + "Cannot confirm this Sale Order due to insufficient stock.\n\nPlease get approval or adjust the quantities." + ) + return super().action_confirm() diff --git a/sale_order_zero_stock_blockage/tests/__init__.py b/sale_order_zero_stock_blockage/tests/__init__.py new file mode 100644 index 00000000000..c95c7283023 --- /dev/null +++ b/sale_order_zero_stock_blockage/tests/__init__.py @@ -0,0 +1,3 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_sale_blockage diff --git a/sale_order_zero_stock_blockage/tests/test_sale_blockage.py b/sale_order_zero_stock_blockage/tests/test_sale_blockage.py new file mode 100644 index 00000000000..4731e787beb --- /dev/null +++ b/sale_order_zero_stock_blockage/tests/test_sale_blockage.py @@ -0,0 +1,59 @@ +from odoo.tests.common import TransactionCase +from odoo.exceptions import UserError + + +class TestSaleZeroStockBlockage(TransactionCase): + + @classmethod + def setUpClass(self): + super().setUpClass() + + self.sale_user = self.env['res.users'].create({ + 'name': 'Salesman Test User', + 'login': 'sales_test_user', + 'email': 'sales_test@example.com', + 'group_ids': [(4, self.env.ref('sales_team.group_sale_salesman').id)], + }) + + self.partner = self.sale_user.partner_id + + self.product_no_stock = self.env['product.product'].create({ + 'name': 'Zero Stock Product', + 'type': 'consu', + 'list_price': 100.0, + }) + + self.sale_order = self.env['sale.order'].with_user(self.sale_user).create({ + 'partner_id': self.partner.id, + 'order_line': [(0, 0, { + 'product_id': self.product_no_stock.id, + 'product_uom_qty': 1.0, + 'price_unit': 100.0, + })], + }) + + def test_block_confirm_no_stock(self): + """ Test that confirming a sale order with zero stock raises an error """ + self.assertEqual(self.product_no_stock.qty_available, 0, "Initial stock should be 0") + with self.assertRaises(UserError): + self.sale_order.action_confirm() + + def test_allow_confirm_no_stock(self): + """ Test that confirming a sale order with zero stock will not raise an error if `zero_stock_approval` is True """ + self.assertEqual(self.product_no_stock.qty_available, 0, "Initial stock should be 0") + self.sale_order.zero_stock_approval = True + self.assertEqual(self.sale_order.zero_stock_approval, True, "Cannot comfirm order of insufficent product") + self.sale_order.action_confirm() + + def test_allow_confirm_with_stock(self): + """ Test that adding stock allows the order to be confirmed """ + stock_location = self.env.ref('stock.stock_location_stock') + self.product_no_stock.is_storable = True + self.env['stock.quant'].create({ + 'product_id': self.product_no_stock.id, + 'location_id': stock_location.id, + 'inventory_quantity': 10.0, + }).action_apply_inventory() + self.assertEqual(self.product_no_stock.qty_available, 10.0, "Stock should be updated to 10") + self.sale_order.action_confirm() + self.assertEqual(self.sale_order.state, 'sale', "Order should be in 'sale' state after confirmation") diff --git a/sale_order_zero_stock_blockage/views/sale_order_view.xml b/sale_order_zero_stock_blockage/views/sale_order_view.xml new file mode 100644 index 00000000000..d24969dc567 --- /dev/null +++ b/sale_order_zero_stock_blockage/views/sale_order_view.xml @@ -0,0 +1,13 @@ + + + + sale.order.view.inherit + sale.order + + + + + + + +