# -*- 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 TestDecimalPrecisionProperty(TransactionCase): """ Property-based tests for decimal precision compliance. **Feature: vendor-bill-editable-totals, Property 5: Decimal precision compliance** """ 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 a vendor bill self.vendor_bill = self.env['account.move'].create({ 'move_type': 'in_invoice', 'partner_id': self.vendor.id, 'invoice_date': '2024-01-01', }) # Create a tax for testing price_total calculations self.tax_10 = self.env['account.tax'].create({ 'name': 'Tax 10%', 'amount': 10.0, 'amount_type': 'percent', 'type_tax_use': 'purchase', 'price_include': False, }) # Get decimal precision for Product Price self.precision = self.env['decimal.precision'].precision_get('Product Price') def _count_decimal_places(self, value): """Helper method to count the number of decimal places in a float.""" # Convert to string and count digits after decimal point value_str = f"{value:.15f}" # Use high precision to capture all decimals if '.' in value_str: # Remove trailing zeros value_str = value_str.rstrip('0').rstrip('.') if '.' in value_str: return len(value_str.split('.')[1]) return 0 @given( price_subtotal=st.floats( min_value=0.001, max_value=1000000.0, allow_nan=False, allow_infinity=False ).map(lambda x: round(x, 10)), # Generate values with excessive decimal places quantity=st.floats(min_value=1.0, max_value=10000.0, allow_nan=False, allow_infinity=False) ) @settings(max_examples=100, deadline=None) def test_property_decimal_precision_price_subtotal(self, price_subtotal, quantity): """ **Feature: vendor-bill-editable-totals, Property 5: Decimal precision compliance** **Validates: Requirements 3.5** Property: For any calculated price_unit value from price_subtotal modification, the system should round the value according to Odoo's configured decimal precision for the Product Price field. """ # Create an invoice line line = self.env['account.move.line'].create({ 'move_id': self.vendor_bill.id, 'product_id': self.product.id, 'name': 'Test Line', 'quantity': quantity, 'price_unit': 1.0, # Initial value, will be recalculated }) # Set the price_subtotal to trigger the onchange line.price_subtotal = price_subtotal line._onchange_price_subtotal() # Count decimal places in the calculated price_unit decimal_places = self._count_decimal_places(line.price_unit) # Verify the property: price_unit should have no more decimal places # than the configured precision self.assertLessEqual( decimal_places, self.precision, msg=f"Decimal precision violation: price_unit={line.price_unit} has " f"{decimal_places} decimal places, but precision is {self.precision}. " f"Input: price_subtotal={price_subtotal}, quantity={quantity}" ) # Also verify that the value is properly rounded expected_rounded = round(price_subtotal / quantity, self.precision) self.assertEqual( line.price_unit, expected_rounded, msg=f"Price unit not properly rounded: expected {expected_rounded}, " f"got {line.price_unit}" ) @given( price_total=st.floats( min_value=0.001, max_value=1000000.0, allow_nan=False, allow_infinity=False ).map(lambda x: round(x, 10)), # Generate values with excessive decimal places quantity=st.floats(min_value=1.0, max_value=10000.0, allow_nan=False, allow_infinity=False), use_tax=st.booleans() ) @settings(max_examples=100, deadline=None) def test_property_decimal_precision_price_total(self, price_total, quantity, use_tax): """ **Feature: vendor-bill-editable-totals, Property 5: Decimal precision compliance** **Validates: Requirements 3.5** Property: For any calculated price_unit value from price_total modification, the system should round the value according to Odoo's configured decimal precision for the Product Price field. """ # Create an invoice line with or without tax tax_ids = [self.tax_10.id] if use_tax else [] line = self.env['account.move.line'].create({ 'move_id': self.vendor_bill.id, 'product_id': self.product.id, 'name': 'Test Line', 'quantity': quantity, 'price_unit': 1.0, # Initial value, will be recalculated 'tax_ids': [(6, 0, tax_ids)], }) # Set the price_total to trigger the onchange line.price_total = price_total line._onchange_price_total() # Count decimal places in the calculated price_unit decimal_places = self._count_decimal_places(line.price_unit) # Verify the property: price_unit should have no more decimal places # than the configured precision self.assertLessEqual( decimal_places, self.precision, msg=f"Decimal precision violation: price_unit={line.price_unit} has " f"{decimal_places} decimal places, but precision is {self.precision}. " f"Input: price_total={price_total}, quantity={quantity}, use_tax={use_tax}" ) # Calculate expected value based on whether tax is used if use_tax: # With tax: need to account for tax factor tax_results = line.tax_ids.compute_all( price_unit=1.0, currency=line.currency_id, quantity=1.0, product=line.product_id, partner=line.move_id.partner_id ) tax_factor = tax_results['total_included'] / tax_results['total_excluded'] expected_price_unit = (price_total / tax_factor) / quantity else: # Without tax: price_total equals price_subtotal expected_price_unit = price_total / quantity expected_rounded = round(expected_price_unit, self.precision) # Verify that the value is properly rounded self.assertEqual( line.price_unit, expected_rounded, msg=f"Price unit not properly rounded: expected {expected_rounded}, " f"got {line.price_unit}" )