diff --git a/bemade_margin_vendor_pricelist/__manifest__.py b/bemade_margin_vendor_pricelist/__manifest__.py index fa8f20c..0a11b66 100644 --- a/bemade_margin_vendor_pricelist/__manifest__.py +++ b/bemade_margin_vendor_pricelist/__manifest__.py @@ -18,7 +18,7 @@ # { 'name': 'Sales Margin on Vendor Price', - 'version': '17.0.0.0.4', + 'version': '17.0.0.0.5', 'summary': 'Enables calculation of sales margins based on vendor pricelists.', 'description': """Adds a new method for calculating """, 'author': 'Bemade Inc.', diff --git a/bemade_margin_vendor_pricelist/models/sale_order_line.py b/bemade_margin_vendor_pricelist/models/sale_order_line.py index a761c08..ebe6986 100644 --- a/bemade_margin_vendor_pricelist/models/sale_order_line.py +++ b/bemade_margin_vendor_pricelist/models/sale_order_line.py @@ -69,7 +69,8 @@ class SaleOrderLine(models.Model): 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): + 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, @@ -97,7 +98,8 @@ class SaleOrderLine(models.Model): """ self.ensure_one() is_order = self.order_id.state in ('sale', 'done') - if is_order and self.qty_to_deliver == 0: + 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')]) @@ -105,7 +107,7 @@ class SaleOrderLine(models.Model): if float_compare(missing, 0.0, precision_rounding=self.product_uom.rounding) == 1: # Not enough reserved, check stock - missing = missing - self.product_id.qty_available + missing = missing - qty_available if float_compare(missing, 0.0, precision_rounding=self.product_uom.rounding) == 1: # Missing some stock to meet demand, return the quantity @@ -118,7 +120,8 @@ class SaleOrderLine(models.Model): return 0 else: # This is a quotation, don't bother with stock reservations - missing = self.product_uom_qty - self.product_id.qty_available + # Also, if available is negative for some reason, + missing = self.product_uom_qty - qty_available if float_compare(missing, 0.0, precision_rounding=self.product_uom.rounding) == 1: return missing diff --git a/bemade_margin_vendor_pricelist/tests/__init__.py b/bemade_margin_vendor_pricelist/tests/__init__.py new file mode 100644 index 0000000..3142f20 --- /dev/null +++ b/bemade_margin_vendor_pricelist/tests/__init__.py @@ -0,0 +1 @@ +from . import test_sale_order_line \ No newline at end of file diff --git a/bemade_margin_vendor_pricelist/tests/test_sale_order_line.py b/bemade_margin_vendor_pricelist/tests/test_sale_order_line.py new file mode 100644 index 0000000..29b6a84 --- /dev/null +++ b/bemade_margin_vendor_pricelist/tests/test_sale_order_line.py @@ -0,0 +1,164 @@ +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': 'product', + '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." + ) + + 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." + ) + + 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." + ) + + 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." + )