vendor_bill_editable_totals/tests/test_price_total_property.py
2025-11-21 18:02:20 +07:00

188 lines
7.2 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import TransactionCase
from hypothesis import given, settings, strategies as st
from decimal import Decimal
class TestPriceTotalProperty(TransactionCase):
"""
Property-based tests for price_total calculation with taxes.
**Feature: vendor-bill-editable-totals, Property 4: Price total to unit price calculation with taxes**
"""
def setUp(self):
super().setUp()
# Create a vendor partner
self.vendor = self.env['res.partner'].create({
'name': 'Test Vendor',
'supplier_rank': 1,
})
# Create a product
self.product = self.env['res.product'].create({
'name': 'Test Product',
'type': 'service',
'list_price': 100.0,
})
# Create various tax configurations for testing
# Tax 1: 10% tax excluded
self.tax_10 = self.env['account.tax'].create({
'name': 'Tax 10%',
'amount': 10.0,
'amount_type': 'percent',
'type_tax_use': 'purchase',
'price_include': False,
})
# Tax 2: 5% tax excluded
self.tax_5 = self.env['account.tax'].create({
'name': 'Tax 5%',
'amount': 5.0,
'amount_type': 'percent',
'type_tax_use': 'purchase',
'price_include': False,
})
# Tax 3: 20% tax excluded
self.tax_20 = self.env['account.tax'].create({
'name': 'Tax 20%',
'amount': 20.0,
'amount_type': 'percent',
'type_tax_use': 'purchase',
'price_include': False,
})
# Tax 4: 15% tax included
self.tax_15_included = self.env['account.tax'].create({
'name': 'Tax 15% Included',
'amount': 15.0,
'amount_type': 'percent',
'type_tax_use': 'purchase',
'price_include': True,
})
# Get decimal precision for Product Price
self.precision = self.env['decimal.precision'].precision_get('Product Price')
def _create_vendor_bill_line(self, quantity, price_unit, tax_ids):
"""Helper method to create a vendor bill line with specified parameters."""
vendor_bill = self.env['account.move'].create({
'move_type': 'in_invoice',
'partner_id': self.vendor.id,
'invoice_date': '2024-01-01',
})
line = self.env['account.move.line'].create({
'move_id': vendor_bill.id,
'product_id': self.product.id,
'name': 'Test Line',
'quantity': quantity,
'price_unit': price_unit,
'tax_ids': [(6, 0, tax_ids)],
})
return line
@given(
price_total=st.floats(min_value=0.01, max_value=1000000.0, allow_nan=False, allow_infinity=False),
quantity=st.floats(min_value=1.0, max_value=10000.0, allow_nan=False, allow_infinity=False),
tax_config=st.integers(min_value=0, max_value=5)
)
@settings(max_examples=100, deadline=None)
def test_property_price_total_with_taxes(self, price_total, quantity, tax_config):
"""
**Feature: vendor-bill-editable-totals, Property 4: Price total to unit price calculation with taxes**
**Validates: Requirements 2.2, 2.3, 2.4**
Property: For any invoice line with non-zero quantity, configured taxes, and a
user-modified price_total value, the recalculated price_unit should result in a
recomputed price_total that matches the user input within the configured decimal precision.
"""
# Map tax_config to different tax configurations
# 0: No taxes
# 1: Single tax (10%)
# 2: Single tax (20%)
# 3: Multiple taxes (10% + 5%)
# 4: Multiple taxes (20% + 5%)
# 5: Tax included (15%)
tax_ids = []
if tax_config == 0:
tax_ids = []
elif tax_config == 1:
tax_ids = [self.tax_10.id]
elif tax_config == 2:
tax_ids = [self.tax_20.id]
elif tax_config == 3:
tax_ids = [self.tax_10.id, self.tax_5.id]
elif tax_config == 4:
tax_ids = [self.tax_20.id, self.tax_5.id]
elif tax_config == 5:
tax_ids = [self.tax_15_included.id]
# Create an invoice line
line = self._create_vendor_bill_line(quantity, 1.0, tax_ids)
# Store the user input price_total
user_input_total = price_total
# Step 1: Set the price_total to trigger the onchange
line.price_total = user_input_total
line._onchange_price_total()
# Step 2: Calculate what the recomputed price_total should be
# using Odoo's tax computation with the new price_unit
if not line.tax_ids:
# No taxes: price_total should equal price_subtotal
recomputed_total = line.price_unit * line.quantity
else:
# With taxes: use Odoo's tax computation API
tax_results = line.tax_ids.compute_all(
price_unit=line.price_unit,
currency=line.currency_id,
quantity=line.quantity,
product=line.product_id,
partner=line.move_id.partner_id
)
recomputed_total = tax_results['total_included']
# Calculate the tolerance based on decimal precision
# The tolerance accounts for:
# 1. Rounding of price_unit to 'precision' decimal places
# 2. Cumulative effect when multiplied by quantity
# 3. Tax calculations that may introduce additional rounding
unit_rounding_error = 0.5 * (10 ** (-self.precision))
base_tolerance = quantity * unit_rounding_error
# For tax calculations, we need a more lenient tolerance because:
# - Tax calculations involve division and multiplication
# - Multiple taxes compound the rounding errors
# - Tax-included calculations are more complex
if tax_ids:
# With taxes, allow for additional rounding in tax calculations
# The tolerance is proportional to the price_total
tolerance = max(base_tolerance * 2, user_input_total * 0.0001) # 0.01% or base tolerance
else:
# Without taxes, use the base tolerance
tolerance = base_tolerance * 1.1 # 10% margin for floating point
# Verify the property: recomputed price_total should match user input
# within the configured decimal precision
difference = abs(recomputed_total - user_input_total)
self.assertLessEqual(
difference,
tolerance,
msg=f"Round-trip accuracy failed for tax_config={tax_config}: "
f"user input={user_input_total:.{self.precision}f}, "
f"price_unit={line.price_unit:.{self.precision}f}, "
f"quantity={quantity:.2f}, "
f"recomputed={recomputed_total:.{self.precision}f}, "
f"difference={difference:.{self.precision+2}f}, "
f"tolerance={tolerance:.{self.precision+2}f}"
)