276 lines
9.4 KiB
Python
276 lines
9.4 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
Standalone test runner for price_total property-based tests.
|
|
This allows running tests without the full Odoo test framework.
|
|
"""
|
|
|
|
import sys
|
|
import os
|
|
|
|
from hypothesis import given, settings, strategies as st
|
|
from decimal import Decimal
|
|
|
|
|
|
class MockEnv:
|
|
"""Mock Odoo environment for testing"""
|
|
|
|
def __getitem__(self, key):
|
|
if key == 'decimal.precision':
|
|
return MockDecimalPrecision()
|
|
return self
|
|
|
|
def precision_get(self, name):
|
|
return 2 # Default precision for Product Price
|
|
|
|
|
|
class MockDecimalPrecision:
|
|
def precision_get(self, name):
|
|
return 2
|
|
|
|
|
|
class MockTax:
|
|
"""Mock tax record"""
|
|
|
|
def __init__(self, amount, price_include=False):
|
|
self.amount = amount
|
|
self.price_include = price_include
|
|
|
|
def compute_all(self, price_unit, currency, quantity, product, partner):
|
|
"""Simplified tax computation"""
|
|
total_excluded = price_unit * quantity
|
|
|
|
if self.price_include:
|
|
# Tax is included in the price
|
|
# price_unit already includes tax
|
|
# We need to extract the tax amount
|
|
tax_factor = 1 + (self.amount / 100.0)
|
|
actual_excluded = total_excluded / tax_factor
|
|
tax_amount = total_excluded - actual_excluded
|
|
total_included = total_excluded
|
|
total_excluded = actual_excluded
|
|
else:
|
|
# Tax is excluded from the price
|
|
tax_amount = total_excluded * (self.amount / 100.0)
|
|
total_included = total_excluded + tax_amount
|
|
|
|
return {
|
|
'total_excluded': total_excluded,
|
|
'total_included': total_included,
|
|
'taxes': [{'amount': tax_amount}]
|
|
}
|
|
|
|
|
|
class MockTaxCollection:
|
|
"""Mock collection of taxes"""
|
|
|
|
def __init__(self, taxes):
|
|
self.taxes = taxes
|
|
|
|
def compute_all(self, price_unit, currency, quantity, product, partner):
|
|
"""Compute taxes for a collection"""
|
|
if not self.taxes:
|
|
total = price_unit * quantity
|
|
return {
|
|
'total_excluded': total,
|
|
'total_included': total,
|
|
'taxes': []
|
|
}
|
|
|
|
# Start with the base amount
|
|
total_excluded = price_unit * quantity
|
|
total_included = total_excluded
|
|
|
|
# Apply each tax
|
|
for tax in self.taxes:
|
|
if tax.price_include:
|
|
# For tax-included, we need to extract the tax from the current total
|
|
tax_factor = 1 + (tax.amount / 100.0)
|
|
actual_excluded = total_included / tax_factor
|
|
total_excluded = actual_excluded
|
|
else:
|
|
# For tax-excluded, add the tax to the total
|
|
tax_amount = total_excluded * (tax.amount / 100.0)
|
|
total_included += tax_amount
|
|
|
|
return {
|
|
'total_excluded': total_excluded,
|
|
'total_included': total_included,
|
|
'taxes': []
|
|
}
|
|
|
|
def __bool__(self):
|
|
return len(self.taxes) > 0
|
|
|
|
def __len__(self):
|
|
return len(self.taxes)
|
|
|
|
|
|
class MockLine:
|
|
"""Mock account.move.line for testing"""
|
|
|
|
def __init__(self, taxes=None):
|
|
self.price_total = 0.0
|
|
self.quantity = 1.0
|
|
self.price_unit = 0.0
|
|
self.move_id = MockMove()
|
|
self.env = MockEnv()
|
|
self.currency_id = None
|
|
self.product_id = None
|
|
self.tax_ids = MockTaxCollection(taxes or [])
|
|
|
|
def _onchange_price_total(self):
|
|
"""Simplified version of the onchange method"""
|
|
if self.move_id.move_type not in ('in_invoice', 'in_refund'):
|
|
return
|
|
|
|
if self.quantity == 0:
|
|
raise ValueError("Cannot calculate unit price: quantity must be greater than zero")
|
|
|
|
# Handle case with no taxes: price_total equals price_subtotal
|
|
if not self.tax_ids:
|
|
new_price_unit = self.price_total / self.quantity
|
|
precision = self.env['decimal.precision'].precision_get('Product Price')
|
|
self.price_unit = round(new_price_unit, precision)
|
|
return
|
|
|
|
# Check if any taxes are price-included
|
|
has_price_included_tax = any(tax.price_include for tax in self.tax_ids.taxes)
|
|
|
|
if has_price_included_tax:
|
|
# For tax-included taxes, price_unit = price_total / quantity
|
|
new_price_unit = self.price_total / self.quantity
|
|
precision = self.env['decimal.precision'].precision_get('Product Price')
|
|
self.price_unit = round(new_price_unit, precision)
|
|
else:
|
|
# For tax-excluded taxes, calculate tax factor
|
|
tax_results = self.tax_ids.compute_all(
|
|
price_unit=1.0,
|
|
currency=self.currency_id,
|
|
quantity=1.0,
|
|
product=self.product_id,
|
|
partner=self.move_id.partner_id
|
|
)
|
|
|
|
# Calculate the tax factor (total_included / total_excluded)
|
|
if tax_results['total_excluded'] != 0:
|
|
tax_factor = tax_results['total_included'] / tax_results['total_excluded']
|
|
else:
|
|
tax_factor = 1.0
|
|
|
|
# Derive price_subtotal from price_total
|
|
derived_price_subtotal = self.price_total / tax_factor
|
|
|
|
# Calculate price_unit from derived price_subtotal
|
|
new_price_unit = derived_price_subtotal / self.quantity
|
|
|
|
# Apply decimal precision rounding
|
|
precision = self.env['decimal.precision'].precision_get('Product Price')
|
|
self.price_unit = round(new_price_unit, precision)
|
|
|
|
|
|
class MockMove:
|
|
def __init__(self):
|
|
self.move_type = 'in_invoice'
|
|
self.partner_id = None
|
|
|
|
|
|
@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(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.
|
|
"""
|
|
precision = 2 # Product Price precision
|
|
|
|
# Map tax_config to different tax configurations
|
|
taxes = []
|
|
if tax_config == 0:
|
|
taxes = []
|
|
elif tax_config == 1:
|
|
taxes = [MockTax(10.0, False)]
|
|
elif tax_config == 2:
|
|
taxes = [MockTax(20.0, False)]
|
|
elif tax_config == 3:
|
|
taxes = [MockTax(10.0, False), MockTax(5.0, False)]
|
|
elif tax_config == 4:
|
|
taxes = [MockTax(20.0, False), MockTax(5.0, False)]
|
|
elif tax_config == 5:
|
|
taxes = [MockTax(15.0, True)]
|
|
|
|
# Create a mock line
|
|
line = MockLine(taxes)
|
|
line.quantity = quantity
|
|
line.price_unit = 1.0
|
|
|
|
# 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
|
|
if not line.tax_ids:
|
|
# No taxes: price_total should equal price_subtotal
|
|
recomputed_total = line.price_unit * line.quantity
|
|
else:
|
|
# With taxes: use tax computation
|
|
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
|
|
unit_rounding_error = 0.5 * (10 ** (-precision))
|
|
base_tolerance = quantity * unit_rounding_error
|
|
|
|
if taxes:
|
|
# With taxes, allow for additional rounding
|
|
tolerance = max(base_tolerance * 2, user_input_total * 0.0001)
|
|
else:
|
|
tolerance = base_tolerance * 1.1
|
|
|
|
# Verify the property
|
|
difference = abs(recomputed_total - user_input_total)
|
|
|
|
assert difference <= tolerance, \
|
|
f"Round-trip accuracy failed for tax_config={tax_config}: " \
|
|
f"user input={user_input_total:.{precision}f}, " \
|
|
f"price_unit={line.price_unit:.{precision}f}, " \
|
|
f"quantity={quantity:.2f}, " \
|
|
f"recomputed={recomputed_total:.{precision}f}, " \
|
|
f"difference={difference:.{precision+2}f}, " \
|
|
f"tolerance={tolerance:.{precision+2}f}"
|
|
|
|
print(f"✓ Test passed: tax_config={tax_config}, input={user_input_total:.2f}, "
|
|
f"price_unit={line.price_unit:.2f}, recomputed={recomputed_total:.2f}, diff={difference:.4f}")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
print("Running Property Test 4: Price total to unit price calculation with taxes")
|
|
print("=" * 70)
|
|
|
|
try:
|
|
test_property_price_total_with_taxes()
|
|
print("\n✓ Property Test 4 passed!")
|
|
sys.exit(0)
|
|
except Exception as e:
|
|
print(f"\n✗ Property Test 4 failed: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
sys.exit(1)
|