diff --git a/bemade_margin_vendor_pricelist/__init__.py b/bemade_margin_vendor_pricelist/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/bemade_margin_vendor_pricelist/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/bemade_margin_vendor_pricelist/__manifest__.py b/bemade_margin_vendor_pricelist/__manifest__.py new file mode 100644 index 0000000..e1d8a65 --- /dev/null +++ b/bemade_margin_vendor_pricelist/__manifest__.py @@ -0,0 +1,31 @@ +# +# Bemade Inc. +# +# Copyright (C) September 2023 Bemade Inc. (). +# Author: Marc Durepos (Contact : marc@bemade.org) +# +# This program is under the terms of the Odoo Proprietary License v1.0 (OPL-1) +# It is forbidden to publish, distribute, sublicense, or sell copies of the Software +# or modified copies of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. +# +{ + "name": "Sales Margin on Vendor Price", + "version": "18.0.0.0.5", + "summary": "Enables calculation of sales margins based on vendor pricelists.", + "description": """Adds a new method for calculating """, + "author": "Bemade Inc.", + "website": "https://www.bemade.org", + "license": "LGPL-3", + "depends": ["sale_stock_margin", "sale_management", "stock"], + "data": ["views/sale_order.xml"], + "installable": True, + "auto_install": False, +} diff --git a/bemade_margin_vendor_pricelist/i18n/bemade_margin_vendor_pricelist.pot b/bemade_margin_vendor_pricelist/i18n/bemade_margin_vendor_pricelist.pot new file mode 100644 index 0000000..145fc85 --- /dev/null +++ b/bemade_margin_vendor_pricelist/i18n/bemade_margin_vendor_pricelist.pot @@ -0,0 +1,58 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * bemade_margin_vendor_pricelist +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 15.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-10-19 15:08+0000\n" +"PO-Revision-Date: 2023-10-19 15:08+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: bemade_margin_vendor_pricelist +#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order__margin_actual +#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__margin_actual +msgid "Margin" +msgstr "" + +#. module: bemade_margin_vendor_pricelist +#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order__margin_percent_actual +#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__margin_percent_actual +msgid "Margin (%)" +msgstr "" + +#. module: bemade_margin_vendor_pricelist +#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__margin_percent_vendor +msgid "Margin (%) on Vendor Price" +msgstr "" + +#. module: bemade_margin_vendor_pricelist +#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__margin_vendor +msgid "Margin on Vendor Price" +msgstr "" + +#. module: bemade_margin_vendor_pricelist +#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__purchase_price_actual +msgid "Purchase Price" +msgstr "" + +#. module: bemade_margin_vendor_pricelist +#: model:ir.model,name:bemade_margin_vendor_pricelist.model_sale_order +msgid "Sales Order" +msgstr "" + +#. module: bemade_margin_vendor_pricelist +#: model:ir.model,name:bemade_margin_vendor_pricelist.model_sale_order_line +msgid "Sales Order Line" +msgstr "" + +#. module: bemade_margin_vendor_pricelist +#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__purchase_price_vendor +msgid "Vendor Price" +msgstr "" diff --git a/bemade_margin_vendor_pricelist/i18n/fr.po b/bemade_margin_vendor_pricelist/i18n/fr.po new file mode 100644 index 0000000..f0dcd19 --- /dev/null +++ b/bemade_margin_vendor_pricelist/i18n/fr.po @@ -0,0 +1,58 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * bemade_margin_vendor_pricelist +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 15.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-10-19 15:08+0000\n" +"PO-Revision-Date: 2023-10-19 15:08+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: bemade_margin_vendor_pricelist +#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order__margin_actual +#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__margin_actual +msgid "Margin" +msgstr "Marge" + +#. module: bemade_margin_vendor_pricelist +#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order__margin_percent_actual +#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__margin_percent_actual +msgid "Margin (%)" +msgstr "Marge (%)" + +#. module: bemade_margin_vendor_pricelist +#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__margin_percent_vendor +msgid "Margin (%) on Vendor Price" +msgstr "Marge (%) sur le prix du fournisseur" + +#. module: bemade_margin_vendor_pricelist +#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__margin_vendor +msgid "Margin on Vendor Price" +msgstr "Marge sur le prix du fournisseur" + +#. module: bemade_margin_vendor_pricelist +#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__purchase_price_actual +msgid "Purchase Price" +msgstr "Prix d'achat" + +#. module: bemade_margin_vendor_pricelist +#: model:ir.model,name:bemade_margin_vendor_pricelist.model_sale_order +msgid "Sales Order" +msgstr "Commande de vente" + +#. module: bemade_margin_vendor_pricelist +#: model:ir.model,name:bemade_margin_vendor_pricelist.model_sale_order_line +msgid "Sales Order Line" +msgstr "Article de commande de vente" + +#. module: bemade_margin_vendor_pricelist +#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__purchase_price_vendor +msgid "Vendor Price" +msgstr "Prix du fournisseur" diff --git a/bemade_margin_vendor_pricelist/models/__init__.py b/bemade_margin_vendor_pricelist/models/__init__.py new file mode 100644 index 0000000..960f6f4 --- /dev/null +++ b/bemade_margin_vendor_pricelist/models/__init__.py @@ -0,0 +1,2 @@ +from . import sale_order +from . import sale_order_line \ No newline at end of file diff --git a/bemade_margin_vendor_pricelist/models/sale_order.py b/bemade_margin_vendor_pricelist/models/sale_order.py new file mode 100644 index 0000000..9f11863 --- /dev/null +++ b/bemade_margin_vendor_pricelist/models/sale_order.py @@ -0,0 +1,23 @@ +from odoo import models, fields, api, _ +from odoo.tools.float_utils import float_is_zero, float_compare + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + gross_profit = fields.Monetary("Gross Profit", compute='_compute_margin_actual', store=False) + + gross_profit_percent = fields.Float( + string='Gross Profit (%)', + compute='_compute_margin_actual', + store=False, + group_operator='avg' + ) + + @api.depends('order_line.gross_profit', 'amount_untaxed') + def _compute_margin_actual(self): + for order in self: + order.gross_profit = sum(order.order_line.mapped('gross_profit')) + order.gross_profit_percent = order.amount_untaxed and order.gross_profit / order.amount_untaxed + + diff --git a/bemade_margin_vendor_pricelist/models/sale_order_line.py b/bemade_margin_vendor_pricelist/models/sale_order_line.py new file mode 100644 index 0000000..3dbabc4 --- /dev/null +++ b/bemade_margin_vendor_pricelist/models/sale_order_line.py @@ -0,0 +1,141 @@ +from odoo import models, fields, api, _ +from odoo.tools.float_utils import float_is_zero, float_compare + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + purchase_price_vendor = fields.Float( + compute="_compute_purchase_price_vendor", + string="Vendor Price", + groups="base.group_user", + digits="Product Price", + ) + + purchase_price_actual = fields.Float( + compute="_compute_actual_gp", + digits="Product Price", + groups="base.group_user", + string="Purchase Price", + ) + + gross_profit = fields.Float( + compute="_compute_actual_gp", + digits="Product Price", + groups="base.group_user", + string="Gross Profit", + ) + + gross_profit_percent = fields.Float( + compute="_compute_actual_gp", + groups="base.group_user", + string="Gross Profit (%)", + ) + + @api.depends( + "purchase_price", + "purchase_price_vendor", + "move_ids.product_id", + "move_ids.product_id.qty_available", + "move_ids.state", + "qty_to_deliver", + ) + def _compute_actual_gp(self): + """We want to use the gross profit based on average inventory valuation when the + sale order line will be completely fulfilled (or has been fulfilled) from stock. + For product not yet in stock we want to use the vendor price. We can also have + blended calculations (partly on vendor price, partly on stock valuation). This + occurs when an order has been or would be partially fulfilled from available + stock. + :return: + """ + non_product_lines = self.filtered(lambda r: not r.product_id) + non_product_lines.purchase_price_actual = 0.0 + non_product_lines.gross_profit = 0.0 + non_product_lines.gross_profit_percent = 0.0 + for line in self - non_product_lines: + stock_missing = line._determine_missing_stock() + if float_is_zero( + stock_missing, precision_rounding=line.product_uom.rounding + ): + # everything is coming from stock, use inventory valuation + line.purchase_price_actual = line.purchase_price + elif ( + float_compare( + line.product_uom_qty, + stock_missing, + precision_rounding=line.product_uom.rounding, + ) + == 0 + ): + # everything is coming from the vendor, use vendor pricing + line.purchase_price_actual = line.purchase_price_vendor + else: + # we have a mix, use blended pricing + qty_from_stock = line.product_uom_qty - stock_missing + line.purchase_price_actual = ( + ( + stock_missing * line.purchase_price_vendor + + qty_from_stock * line.purchase_price + ) + / line.product_uom_qty + if line.product_uom_qty != 0 + else 0 + ) + line.gross_profit = line.price_subtotal - ( + line.purchase_price_actual * line.product_uom_qty + ) + line.gross_profit_percent = ( + line.price_subtotal and line.gross_profit / line.price_subtotal + if line.price_subtotal != 0 + else 0 + ) + + def _determine_missing_stock(self) -> float: + """Compute how much stock is missing to meet an order line's demand. In the + case of a quotation line, available stock is checked as if the order were to be + placed immediately. + + :return: The quantity missing from available stock to fulfill the line, in + the unit of measure matching self.product_uom. + """ + self.ensure_one() + is_order = self.order_id.state == "sale" + qty_available = max(0, self.product_id.qty_available) + if is_order and self.qty_to_deliver <= 0: + return 0 + elif is_order and self.qty_to_deliver > 0: + reserved = sum( + [ + q.reserved_quantity + for q in self.move_ids.mapped("move_line_ids").mapped("quant_id") + ] + ) + return max(0, self.qty_to_deliver - reserved - qty_available) + else: + # This is a quotation, don't bother with stock reservations + # Also, if available is negative for some reason, + return max(0, self.product_uom_qty - qty_available) + + @api.depends("product_id", "product_id.seller_ids", "product_id.seller_ids.price") + def _compute_purchase_price_vendor(self): + for line in self: + product = line.product_id + suppinfos = product.seller_ids.sorted("sequence") + if not suppinfos: + line.purchase_price_vendor = 0.0 + continue + suppinfo = suppinfos[0] + purch_currency = suppinfo.currency_id + to_cur = line.currency_id or line.order_id.currency_id + line.purchase_price_vendor = ( + purch_currency._convert( + from_amount=suppinfo.price, + to_currency=to_cur, + company=line.company_id or self.env.company, + date=line.order_id.date_order or fields.Date.today(), + round=False, + ) + if to_cur and suppinfo.price + else suppinfo.price + ) diff --git a/bemade_margin_vendor_pricelist/tests/__init__.py b/bemade_margin_vendor_pricelist/tests/__init__.py new file mode 100644 index 0000000..014c7c5 --- /dev/null +++ b/bemade_margin_vendor_pricelist/tests/__init__.py @@ -0,0 +1 @@ +from . import test_margins \ No newline at end of file diff --git a/bemade_margin_vendor_pricelist/tests/test_margins.py b/bemade_margin_vendor_pricelist/tests/test_margins.py new file mode 100644 index 0000000..f31fbff --- /dev/null +++ b/bemade_margin_vendor_pricelist/tests/test_margins.py @@ -0,0 +1,390 @@ +from odoo.tests import TransactionCase, tagged +from unittest.mock import patch, PropertyMock + + +@tagged("-at_install", "post_install") +class TestSaleOrderLine(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + # Create a test vendor + cls.vendor = cls.env["res.partner"].create( + { + "name": "Test Vendor", + "supplier_rank": 1, + } + ) + + # Create a test product + cls.product = cls.env["product.product"].create( + { + "name": "Test Product", + "type": "consu", + "is_storable": True, + "list_price": 100.0, + "standard_price": 80.0, + } + ) + + # Link supplier info to the product + cls.supplierinfo = cls.env["product.supplierinfo"].create( + { + "partner_id": cls.vendor.id, + "product_tmpl_id": cls.product.product_tmpl_id.id, + "min_qty": 1, + "price": 75.0, # Vendor price + "currency_id": cls.env.ref( + "base.USD" + ).id, # Assuming USD for simplicity + } + ) + + # Create a test sale order + cls.sale_order = cls.env["sale.order"].create( + { + "partner_id": cls.env.ref("base.res_partner_1").id, + # Assuming a default partner + } + ) + + # Create a test sale order line + cls.sale_order_line = cls.env["sale.order.line"].create( + { + "order_id": cls.sale_order.id, + "product_id": cls.product.id, + "product_uom_qty": 10, + "price_unit": 120.0, + } + ) + + def test_purchase_price_all_from_stock(self): + """Test when all quantity is fulfilled from stock.""" + with patch.object( + self.sale_order_line.product_id.__class__, + "qty_available", + new_callable=PropertyMock, + ) as mock_qty_available: + mock_qty_available.return_value = 10 # All available + + # Recompute the fields + self.sale_order_line._compute_actual_gp() + + # Assert purchase_price_actual equals purchase_price (from stock) + self.assertEqual( + self.sale_order_line.purchase_price_actual, + self.sale_order_line.purchase_price, + "Purchase price should be from stock when all quantity is available.", + ) + + # Assert gross_profit is calculated correctly + expected_gross_profit = self.sale_order_line.price_subtotal - ( + self.sale_order_line.purchase_price_actual + * self.sale_order_line.product_uom_qty + ) + self.assertAlmostEqual( + self.sale_order_line.gross_profit, + expected_gross_profit, + msg="Gross profit should be correctly calculated from stock purchase price.", + ) + # Ensure the SO is correct as well + self.assertSaleOrderComputesLikeSaleOrderLine() + + def assertSaleOrderComputesLikeSaleOrderLine(self): + self.assertAlmostEqual( + self.sale_order_line.gross_profit, + self.sale_order.gross_profit, + ) + self.assertAlmostEqual( + self.sale_order_line.gross_profit_percent, + self.sale_order.gross_profit_percent, + ) + + def test_purchase_price_all_from_vendor(self): + """Test when all quantity is fulfilled from vendor.""" + with patch.object( + self.sale_order_line.product_id.__class__, + "qty_available", + new_callable=PropertyMock, + ) as mock_qty_available: + mock_qty_available.return_value = 0 + + # Assert purchase_price_actual equals purchase_price_vendor (from vendor) + self.assertEqual( + self.sale_order_line.purchase_price_actual, + self.sale_order_line.purchase_price_vendor, + "Purchase price should be from vendor when all quantity is missing from stock.", + ) + + # Assert gross_profit is calculated correctly + expected_gross_profit = self.sale_order_line.price_subtotal - ( + self.sale_order_line.purchase_price_actual + * self.sale_order_line.product_uom_qty + ) + self.assertAlmostEqual( + self.sale_order_line.gross_profit, + expected_gross_profit, + msg="Gross profit should be correctly calculated from vendor purchase price.", + ) + # Ensure the SO is correct as well + self.assertSaleOrderComputesLikeSaleOrderLine() + + def test_purchase_price_mixed(self): + """Test when quantity is partially fulfilled from stock and partially from vendor.""" + with patch.object( + self.sale_order_line.product_id.__class__, + "qty_available", + new_callable=PropertyMock, + ) as mock_qty_available: + mock_qty_available.return_value = 5 + # Calculate expected blended purchase price + qty_from_vendor = 5 + qty_from_stock = 5 + expected_purchase_price = ( + (qty_from_vendor * self.sale_order_line.purchase_price_vendor) + + (qty_from_stock * self.sale_order_line.purchase_price) + ) / self.sale_order_line.product_uom_qty + + self.assertAlmostEqual( + self.sale_order_line.purchase_price_actual, + expected_purchase_price, + msg="Purchase price should be a blend of stock and vendor prices.", + ) + + # Assert gross_profit is calculated correctly + expected_gross_profit = self.sale_order_line.price_subtotal - ( + expected_purchase_price * self.sale_order_line.product_uom_qty + ) + self.assertAlmostEqual( + self.sale_order_line.gross_profit, + expected_gross_profit, + msg="Gross profit should be correctly calculated from blended purchase price.", + ) + # Ensure the SO is correct as well + self.assertSaleOrderComputesLikeSaleOrderLine() + + def test_negative_available_stock_zero_qty_ordered(self): + """We had a division by zero error with a zero product_uom_qty field + and a negative available stock. This test aims to root out this and + other possible causes of a div by zero error.""" + with patch.object( + self.sale_order_line.product_id.__class__, + "qty_available", + new_callable=PropertyMock, + ) as mock_qty_available: + mock_qty_available.return_value = -1 # Negative available stock + + # Set product_uom_qty to zero + self.sale_order_line.product_uom_qty = 0 + # Recompute the fields + self.sale_order_line._compute_actual_gp() + + # Assert gross_profit is 0 + self.assertEqual( + self.sale_order_line.gross_profit, + 0.0, + "Gross profit should be 0 when product_uom_qty is zero.", + ) + + # Assert gross_profit_percent is 0 + self.assertEqual( + self.sale_order_line.gross_profit_percent, + 0.0, + "Gross profit percent should be 0 when product_uom_qty is zero.", + ) + # Ensure the SO is correct as well + self.assertSaleOrderComputesLikeSaleOrderLine() + + def test_purchase_price_all_from_stock_confirmed(self): + """Test when all quantity is fulfilled from stock.""" + self.sale_order.action_confirm() + with patch.object( + self.sale_order_line.product_id.__class__, + "qty_available", + new_callable=PropertyMock, + ) as mock_qty_available: + mock_qty_available.return_value = 10 # All available + + # Recompute the fields + self.sale_order_line._compute_actual_gp() + + # Assert purchase_price_actual equals purchase_price (from stock) + self.assertEqual( + self.sale_order_line.purchase_price_actual, + self.sale_order_line.purchase_price, + "Purchase price should be from stock when all quantity is available.", + ) + + # Assert gross_profit is calculated correctly + expected_gross_profit = self.sale_order_line.price_subtotal - ( + self.sale_order_line.purchase_price_actual + * self.sale_order_line.product_uom_qty + ) + self.assertAlmostEqual( + self.sale_order_line.gross_profit, + expected_gross_profit, + msg="Gross profit should be correctly calculated from stock purchase price.", + ) + # Ensure the SO is correct as well + self.assertSaleOrderComputesLikeSaleOrderLine() + + def test_purchase_price_all_from_vendor_confirmed(self): + """Test when all quantity is fulfilled from vendor.""" + self.sale_order.action_confirm() + with patch.object( + self.sale_order_line.product_id.__class__, + "qty_available", + new_callable=PropertyMock, + ) as mock_qty_available: + mock_qty_available.return_value = 0 + + # Assert purchase_price_actual equals purchase_price_vendor (from vendor) + self.assertEqual( + self.sale_order_line.purchase_price_actual, + self.sale_order_line.purchase_price_vendor, + "Purchase price should be from vendor when all quantity is missing from stock.", + ) + + # Assert gross_profit is calculated correctly + expected_gross_profit = self.sale_order_line.price_subtotal - ( + self.sale_order_line.purchase_price_actual + * self.sale_order_line.product_uom_qty + ) + self.assertAlmostEqual( + self.sale_order_line.gross_profit, + expected_gross_profit, + msg="Gross profit should be correctly calculated from vendor purchase price.", + ) + # Ensure the SO is correct as well + self.assertSaleOrderComputesLikeSaleOrderLine() + + def test_purchase_price_mixed_confirmed(self): + """Test when quantity is partially fulfilled from stock and partially from vendor.""" + self.sale_order.action_confirm() + with patch.object( + self.sale_order_line.product_id.__class__, + "qty_available", + new_callable=PropertyMock, + ) as mock_qty_available: + mock_qty_available.return_value = 5 + # Calculate expected blended purchase price + qty_from_vendor = 5 + qty_from_stock = 5 + expected_purchase_price = ( + (qty_from_vendor * self.sale_order_line.purchase_price_vendor) + + (qty_from_stock * self.sale_order_line.purchase_price) + ) / self.sale_order_line.product_uom_qty + + self.assertAlmostEqual( + self.sale_order_line.purchase_price_actual, + expected_purchase_price, + msg="Purchase price should be a blend of stock and vendor prices.", + ) + + # Assert gross_profit is calculated correctly + expected_gross_profit = self.sale_order_line.price_subtotal - ( + expected_purchase_price * self.sale_order_line.product_uom_qty + ) + self.assertAlmostEqual( + self.sale_order_line.gross_profit, + expected_gross_profit, + msg="Gross profit should be correctly calculated from blended purchase price.", + ) + # Ensure the SO is correct as well + self.assertSaleOrderComputesLikeSaleOrderLine() + + def test_negative_available_stock_zero_qty_ordered_confirmed(self): + """We had a division by zero error with a zero product_uom_qty field + and a negative available stock. This test aims to root out this and + other possible causes of a div by zero error.""" + self.sale_order.action_confirm() + with patch.object( + self.sale_order_line.product_id.__class__, + "qty_available", + new_callable=PropertyMock, + ) as mock_qty_available: + mock_qty_available.return_value = -1 # Negative available stock + + # Set product_uom_qty to zero + self.sale_order_line.product_uom_qty = 0 + # Recompute the fields + self.sale_order_line._compute_actual_gp() + + # Assert gross_profit is 0 + self.assertEqual( + self.sale_order_line.gross_profit, + 0.0, + "Gross profit should be 0 when product_uom_qty is zero.", + ) + + # Assert gross_profit_percent is 0 + self.assertEqual( + self.sale_order_line.gross_profit_percent, + 0.0, + "Gross profit percent should be 0 when product_uom_qty is zero.", + ) + # Ensure the SO is correct as well + self.assertSaleOrderComputesLikeSaleOrderLine() + + def test_determine_missing_stock_all_reserved(self): + """Test the actual _determine_missing_stock method when all stock is reserved.""" + # Set initial stock for the product + location = self.env["stock.warehouse"].search([])[0].lot_stock_id + quant = self.env["stock.quant"].create( + { + "product_id": self.product.id, + "location_id": location.id, + "inventory_quantity": 10, + "product_uom_id": self.product.uom_id.id, + } + ) + quant.action_apply_inventory() + + self.sale_order.action_confirm() + # Force the move to be reserved + self.sale_order.picking_ids.action_assign() + + # Now, call _determine_missing_stock without mocking + missing_stock = self.sale_order_line._determine_missing_stock() + + # Assert that missing_stock is 0 + self.assertEqual( + missing_stock, + 0, + "_determine_missing_stock should return 0 when all stock is reserved.", + ) + + # Additionally, verify that purchase_price_actual is from stock + self.sale_order_line._compute_actual_gp() + + self.assertEqual( + self.sale_order_line.purchase_price_actual, + self.sale_order_line.purchase_price, + "Purchase price should be from stock when all stock is reserved.", + ) + + # Assert gross_profit is calculated correctly + expected_gross_profit = self.sale_order_line.price_subtotal - ( + self.sale_order_line.purchase_price_actual + * self.sale_order_line.product_uom_qty + ) + self.assertAlmostEqual( + self.sale_order_line.gross_profit, + expected_gross_profit, + msg="Gross profit should be correctly calculated when all stock is reserved.", + ) + + # Assert gross_profit_percent is calculated correctly + if self.sale_order_line.price_subtotal: + expected_gross_profit_percent = ( + self.sale_order_line.gross_profit / self.sale_order_line.price_subtotal + ) + else: + expected_gross_profit_percent = 0.0 + + self.assertAlmostEqual( + self.sale_order_line.gross_profit_percent, + expected_gross_profit_percent, + msg="Gross profit percent should be correctly calculated when all stock is reserved.", + ) + # Ensure the SO is correct as well + self.assertSaleOrderComputesLikeSaleOrderLine() diff --git a/bemade_margin_vendor_pricelist/views/sale_order.xml b/bemade_margin_vendor_pricelist/views/sale_order.xml new file mode 100644 index 0000000..a030b71 --- /dev/null +++ b/bemade_margin_vendor_pricelist/views/sale_order.xml @@ -0,0 +1,57 @@ + + + + + sale.order.margin.view.form.inherit + sale.order + + + + + + + + + sale.order.line.margin.view.form.inherit + sale.order + + + + + + + + + + sale.order.view.form + + sale.order + + + + + + + + + + + + + + + + + + \ No newline at end of file