bemade_margin_vendor_pricelist: Fix division by zero

Added tests and fixed an edge case where computing the gross profit
resulted in a division by zero. This happened in production with a zero
ordered quantity on a sales order line and a negative product
availability value.

Also added some test cases to ensure that the calculations for GP and GP
% are correct across logically different cases.
This commit is contained in:
Marc Durepos 2024-09-19 09:52:57 -04:00
parent af3fa7cda2
commit 5f6fb6002a
4 changed files with 173 additions and 5 deletions

View file

@ -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.',

View file

@ -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

View file

@ -0,0 +1 @@
from . import test_sale_order_line

View file

@ -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."
)