Odoo18-Base/addons/l10n_jo_edi/tests/test_jo_edi_precision.py

189 lines
10 KiB
Python
Raw Permalink Normal View History

2025-01-06 10:57:38 +07:00
from odoo import Command
from odoo.tests import tagged
from odoo.tools.float_utils import float_is_zero
from odoo.addons.l10n_jo_edi.tests.jo_edi_common import JoEdiCommon
from odoo.addons.l10n_jo_edi.models.account_edi_xml_ubl_21_jo import JO_MAX_DP
@tagged('post_install_l10n', 'post_install', '-at_install')
class TestJoEdiPrecision(JoEdiCommon):
def _equality_check(self, vals_dict, up_to_jo_max_dp=True):
def equal_strict(val1, val2):
return val1 == val2
def equal_jo_max_dp(val1, val2):
return float_is_zero(val1 - val2, JO_MAX_DP)
equals = equal_jo_max_dp if up_to_jo_max_dp else equal_strict
first_tuple = None
error_message = ""
for label, value in vals_dict.items():
if not first_tuple:
first_tuple = (label, value)
else:
if not equals(value, first_tuple[1]):
error_message += f"{label} ({value}) != {first_tuple[0]} ({first_tuple[1]})\n"
return error_message
def _extract_vals_from_subtotals(self, subtotals, defaults):
for subtotal in subtotals:
tax_percent = float(subtotal.findtext('{*}TaxCategory/{*}Percent', default=-1))
if tax_percent == -1: # special (fixed amount) tax
defaults.update({
'taxable_amount_special': float(subtotal.findtext('{*}TaxableAmount')),
'tax_amount_special': float(subtotal.findtext('{*}TaxAmount')),
})
defaults['total_tax_amount'] += defaults['tax_amount_special']
else:
defaults.update({
'taxable_amount_general': float(subtotal.findtext('{*}TaxableAmount')),
'tax_amount_general_subtotal': float(subtotal.findtext('{*}TaxAmount')),
'tax_percent': tax_percent / 100,
})
defaults['total_tax_amount'] += defaults['tax_amount_general_subtotal']
return defaults
def _validate_jo_edi_numbers(self, xml_string):
"""
TLDR: This method checks that units sum up to total values.
===================================================================================================
Problem statement:
When an EDI is submitted to JoFotara portal, multiple validations are executed to ensure invoice data integrity.
The most important ones of these validations are the following:-
--------------------------- invoice line level ---------------------------
1. line_extension_amount = (price_unit * quantity) - discount
2. taxable_amount = line_extension_amount
3. rounding_amount = line_extension_amount + general_tax_amount + special_tax_amount
--------------------------- invoice level ---------------------------------
4. tax_exclusive_amount = sum(price_unit * quantity)
5. tax_inclusive_amount = sum(price_unit * quantity - discount + general_tax_amount + special_tax_amount)
6. payable_amount = tax_inclusive_amount
-------------------------------------------------------------------------------
The JoFotara portal, however, has no tolerance with rounding errors up to 9 decimal places.
Hence, the reported values are expected to be up to 9 decimal places,
and the aggregated units should match reported totals up to 9 decimal places.
Moreover, reported totals have to equal (or at least be as close as possible) to totals stored in Odoo.
And since the JOD has precision of 3 decimal places, everything is stored in Odoo approximated to 3 decimal places.
-------------------------------------------------------------------------------
This method runs validations in a fashion similar to those running on the JoFotara portal.
It returns all the errors encountered as a string.
"""
root = self.get_xml_tree_from_string(xml_string)
error_message = ""
total_discount = float(root.findtext('./{*}AllowanceCharge/{*}Amount'))
total_tax = float(root.findtext('./{*}TaxTotal/{*}TaxAmount', default=0))
tax_exclusive_amount = float(root.findtext('./{*}LegalMonetaryTotal/{*}TaxExclusiveAmount'))
tax_inclusive_amount = float(root.findtext('./{*}LegalMonetaryTotal/{*}TaxInclusiveAmount'))
monetary_values_discount = float(root.findtext('./{*}LegalMonetaryTotal/{*}AllowanceTotalAmount'))
payable_amount = float(root.findtext('./{*}LegalMonetaryTotal/{*}PayableAmount'))
error_message += self._equality_check({ # They have to be exactly the same, no decimal difference is tolerated
'Monetary Values discount': monetary_values_discount,
'Total Discount': total_discount,
}, up_to_jo_max_dp=False)
error_message += self._equality_check({ # They have to be exactly the same, no decimal difference is tolerated
'Payable Amount': payable_amount,
'Tax Inclusive Amount': tax_inclusive_amount,
}, up_to_jo_max_dp=False)
lines = []
for xml_line in root.findall('./{*}InvoiceLine'):
line_extension_amount = float(xml_line.findtext('{*}LineExtensionAmount'))
line = {
'id': xml_line.findtext('{*}ID'),
'quantity': float(xml_line.findtext('{*}InvoicedQuantity')),
'line_extension_amount': line_extension_amount,
'tax_amount_general': float(xml_line.findtext('{*}TaxTotal/{*}TaxAmount', default=0)),
'rounding_amount': float(xml_line.findtext('{*}TaxTotal/{*}RoundingAmount', default=line_extension_amount)), # defaults to line_extension_amount in the absence of taxes
**self._extract_vals_from_subtotals(
subtotals=xml_line.findall('{*}TaxTotal/{*}TaxSubtotal'),
defaults={
'taxable_amount_general': line_extension_amount,
'tax_amount_general_subtotal': 0,
'tax_percent': 0,
'taxable_amount_special': line_extension_amount,
'tax_amount_special': 0,
'total_tax_amount': 0,
}),
'price_unit': float(xml_line.findtext('{*}Price/{*}PriceAmount')),
'discount': float(xml_line.findtext('{*}Price/{*}AllowanceCharge/{*}Amount')),
}
lines.append(line)
line_errors = self._equality_check({
# taxable_amount = line_extension_amount = price_unit * quantity - discount
'General Taxable Amount': line['taxable_amount_general'],
'Special Taxable Amount': line['taxable_amount_special'],
'Line Extension Amount': line['line_extension_amount'],
'Price Unit * Quantity - Discount': line['price_unit'] * line['quantity'] - line['discount'],
}) + self._equality_check({
# rounding_amount = line_extension_amount + total_tax_amount
'Rounding Amount': line['rounding_amount'],
'Line Extension Amount + Total Tax': line['line_extension_amount'] + line['total_tax_amount'],
}) + self._equality_check({
'General Tax Amount': line['tax_amount_general'],
'General Tax Amount in subtotal': line['tax_amount_general_subtotal'],
'Taxable Amount * Tax Percent': (line['taxable_amount_general'] + line['tax_amount_special']) * line['tax_percent'],
})
if line_errors:
error_message += f"Errors on the line {line['id']}\n"
error_message += line_errors
error_message += "-------------------------------------------------------------------------\n"
aggregated_tax_exclusive_amount = sum(line['price_unit'] * line['quantity'] for line in lines)
aggregated_tax_inclusive_amount = sum(line['price_unit'] * line['quantity'] - line['discount'] + line['total_tax_amount'] for line in lines)
aggregated_tax_amount = sum(line['tax_amount_general'] for line in lines)
aggregated_discount_amount = sum(line['discount'] for line in lines)
error_message += self._equality_check({
'Tax Exclusive Amount': tax_exclusive_amount,
'Aggregated Tax Exclusive Amount': aggregated_tax_exclusive_amount,
}) + self._equality_check({
'Tax Inclusive Amount': tax_inclusive_amount,
'Aggregated Tax Inclusive Amount': aggregated_tax_inclusive_amount,
'Tax Exclusive Amount - Total Discount + Total Tax': tax_exclusive_amount - total_discount + sum(line['total_tax_amount'] for line in lines),
}) + self._equality_check({
'Tax Amount': total_tax,
'Aggregated Tax Amount': aggregated_tax_amount,
}) + self._equality_check({
'Discount Amount': total_discount,
'Aggregated Discount Amount': aggregated_discount_amount,
})
return error_message
def test_jo_sales_invoice_precision(self):
eur = self.env.ref('base.EUR')
self.setup_currency_rate(eur, 1.41)
self.company.l10n_jo_edi_taxpayer_type = 'sales'
self.company.l10n_jo_edi_sequence_income_source = '16683693'
invoice_vals = {
'name': 'TestEIN022',
'currency_id': eur.id,
'date': '2023-11-12',
'invoice_line_ids': [
Command.create({
'product_id': self.product_a.id,
'quantity': 3.48,
'price_unit': 1.56,
'discount': 2.5,
'tax_ids': [Command.set(self.jo_general_tax_16_included.ids)],
}),
Command.create({
'product_id': self.product_b.id,
'quantity': 6.02,
'price_unit': 2.79,
'discount': 2.5,
'tax_ids': [Command.set(self.jo_general_tax_16_included.ids)],
}),
],
}
invoice = self._l10n_jo_create_invoice(invoice_vals)
generated_file = self.env['account.edi.xml.ubl_21.jo']._export_invoice(invoice)[0]
errors = self._validate_jo_edi_numbers(generated_file)
self.assertFalse(errors, errors)