odoo18/addons/l10n_sa_edi/tests/common.py

370 lines
16 KiB
Python

# coding: utf-8
import json
from base64 import b64decode
from odoo import Command
from odoo.tests import tagged
from odoo.addons.account_edi.tests.common import AccountEdiTestCommon
@tagged('post_install_l10n', '-at_install', 'post_install')
class TestSaEdiCommon(AccountEdiTestCommon):
"""
Base test class for Saudi Arabia EDI functionality.
Sets up test data for ZATCA (Saudi tax authority) compliance testing including:
- Company with Saudi-specific fields
- Partners (company and individual)
- Products and taxes
- XPath templates for XML comparison
"""
@classmethod
@AccountEdiTestCommon.setup_edi_format('l10n_sa_edi.edi_sa_zatca')
@AccountEdiTestCommon.setup_chart_template('sa')
@AccountEdiTestCommon.setup_country('sa')
def setUpClass(cls):
super().setUpClass()
# Setup frequently used references
cls.company = cls.company_data['company']
cls.saudi_arabia = cls.env.ref('base.sa')
cls.riyadh = cls._get_or_create_state('Riyadh', 'RUH', cls.saudi_arabia)
# Setup test data
cls._setup_company()
cls._setup_branches()
cls._setup_partners()
cls._setup_products()
cls._setup_taxes()
cls._setup_journal()
cls._setup_xpath_templates()
@classmethod
def _get_company_vals(cls, defaults=None):
return {
'name': 'SA Company Test',
'email': 'info@company.saexample.com',
'phone': '+966 51 234 5678',
'vat': '311111111111113',
# Address fields
'street': 'Al Amir Mohammed Bin Abdul Aziz Street',
'street2': 'Testomania',
'city': 'المدينة المنورة',
'zip': '42317',
'country_id': cls.saudi_arabia.id,
'state_id': cls.riyadh.id,
# Saudi-specific fields
'l10n_sa_edi_building_number': '1234',
'l10n_sa_edi_plot_identification': '1234',
'l10n_sa_additional_identification_number': '2525252525252',
'l10n_sa_additional_identification_scheme': 'CRN', # Commercial Registration Number
**(defaults or {})
}
@classmethod
def _setup_company(cls):
"""Configure the test company with Saudi Arabia specific settings."""
cls.company.write(cls._get_company_vals())
@classmethod
def _setup_branches(cls):
vals = cls._get_company_vals({"name": "SA Branch", "parent_id": cls.company.id})
cls.sa_branch = cls._create_company(**vals)
@classmethod
def _setup_partners(cls):
"""Create test partners for different invoice types."""
# Standard invoice partner (company)
cls.partner_sa = cls._create_saudi_company_partner()
# Simplified invoice partner (individual)
cls.partner_sa_simplified = cls._create_saudi_individual_partner()
@classmethod
def _create_saudi_company_partner(cls):
"""Create a Saudi company partner with full ZATCA requirements."""
return cls.env['res.partner'].create({
'name': 'Saud Ahmed',
'ref': 'Saudi Aramco',
'company_type': 'company',
'lang': 'en_US',
# Contact info
'email': 'saudi.aramco@example.com',
'phone': '+966556666666',
# Tax info
'vat': '311111111111113',
'l10n_sa_additional_identification_scheme': 'CRN',
'l10n_sa_additional_identification_number': '353535353535353',
# Address
'street': '4557 King Salman St',
'street2': 'Neighbor!',
'city': 'Riyadh',
'zip': '94538',
'state_id': cls.riyadh.id,
'country_id': cls.saudi_arabia.id,
# Saudi-specific address fields
'l10n_sa_edi_building_number': '12300',
'l10n_sa_edi_plot_identification': '2323',
})
@classmethod
def _create_saudi_individual_partner(cls):
"""Create a Saudi individual partner for simplified invoices."""
return cls.env['res.partner'].create({
'name': 'Mohammed Ali',
'ref': 'Mohammed Ali',
'company_type': 'person',
'lang': 'en_US',
'country_id': cls.saudi_arabia.id,
'state_id': cls.riyadh.id,
# Simplified invoices use different ID schemes
'l10n_sa_additional_identification_scheme': 'MOM', # Momra License
'l10n_sa_additional_identification_number': '3123123213131',
})
@classmethod
def _setup_products(cls):
"""Create test products."""
cls.product_a = cls._create_product(name='Product A', standard_price=320.0, default_code='P0001')
cls.product_b = cls._create_product(name='Product B', standard_price=15.8, default_code='P0002')
cls.product_burger = cls._create_product(name='Burger', standard_price=265.0)
@classmethod
def _setup_taxes(cls):
"""Setup tax references."""
# Standard 15% VAT in Saudi Arabia
cls.tax_15 = cls.env['account.tax'].search([
('company_id', '=', cls.company.id),
('amount', '=', 15.0)
], limit=1)
@classmethod
def _setup_journal(cls):
"""Setup and configure the sales journal."""
cls.customer_invoice_journal = cls.env['account.journal'].search([
('company_id', '=', cls.company.id),
('type', '=', 'sale')
], limit=1)
# Load ZATCA demo data (certificates, etc.)
cls.customer_invoice_journal._l10n_sa_load_edi_demo_data()
PCSID_Data = json.loads(cls.customer_invoice_journal.l10n_sa_production_csid_json)
pcsid_certificate = cls.env['certificate.certificate'].create({
'name': 'PCSID Certificate',
'content': b64decode(PCSID_Data['binarySecurityToken']),
})
cls.customer_invoice_journal.l10n_sa_production_csid_certificate_id = pcsid_certificate
@classmethod
def _setup_xpath_templates(cls):
"""
Setup XPath templates for XML testing.
These remove or replace dynamic elements (IDs, UUIDs) that change
between test runs to allow XML comparison.
"""
cls.remove_ubl_extensions_xpath = '''<xpath expr="//*[local-name()='UBLExtensions']" position="replace"/>'''
# Common replacements for all document types
common_replacements = '''
<xpath expr="(//*[local-name()='Invoice']/*[local-name()='ID'])[1]" position="replace">
<cbc:ID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:ID>
</xpath>
<xpath expr="(//*[local-name()='Invoice']/*[local-name()='UUID'])[1]" position="replace">
<cbc:UUID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:UUID>
</xpath>
<xpath expr="(//*[local-name()='Contact']/*[local-name()='ID'])[1]" position="replace">
<cbc:ID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:ID>
</xpath>
<xpath expr="(//*[local-name()='Contact']/*[local-name()='ID'])[2]" position="replace">
<cbc:ID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:ID>
</xpath>
'''
# Invoice-specific replacements
cls.invoice_applied_xpath = common_replacements + '''
<xpath expr="//*[local-name()='PaymentMeans']/*[local-name()='InstructionID']" position="replace">
<cbc:InstructionID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:InstructionID>
</xpath>
<xpath expr="(//*[local-name()='PaymentMeans']/*[local-name()='PaymentID'])" position="replace">
<cbc:PaymentID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:PaymentID>
</xpath>
<xpath expr="//*[local-name()='InvoiceLine']/*[local-name()='ID']" position="replace">
<cbc:ID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:ID>
</xpath>
'''
# Credit note specific replacements
cls.credit_note_applied_xpath = common_replacements + '''
<xpath expr="(//*[local-name()='OrderReference']/*[local-name()='ID'])[1]" position="replace">
<cbc:ID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:ID>
</xpath>
<xpath expr="(//*[local-name()='InvoiceDocumentReference']/*[local-name()='ID'])[1]" position="replace">
<cbc:ID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:ID>
</xpath>
<xpath expr="(//*[local-name()='PaymentMeans']/*[local-name()='InstructionNote'])" position="replace">
<cbc:InstructionNote xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:InstructionNote>
</xpath>
<xpath expr="(//*[local-name()='PaymentMeans']/*[local-name()='PaymentID'])" position="replace">
<cbc:PaymentID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:PaymentID>
</xpath>
<xpath expr="//*[local-name()='InvoiceLine']/*[local-name()='ID']" position="replace">
<cbc:ID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:ID>
</xpath>
'''
# Debit note specific replacements
cls.debit_note_applied_xpath = common_replacements + '''
<xpath expr="(//*[local-name()='OrderReference']/*[local-name()='ID'])[1]" position="replace">
<cbc:ID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:ID>
</xpath>
<xpath expr="(//*[local-name()='InvoiceDocumentReference']/*[local-name()='ID'])[1]" position="replace">
<cbc:ID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:ID>
</xpath>
<xpath expr="//*[local-name()='InvoiceLine']/*[local-name()='ID']" position="replace">
<cbc:ID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:ID>
</xpath>
<xpath expr="//*[local-name()='PaymentMeans']/*[local-name()='InstructionID']" position="replace">
<cbc:InstructionID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:InstructionID>
</xpath>
<xpath expr="(//*[local-name()='PaymentMeans']/*[local-name()='PaymentID'])" position="replace">
<cbc:PaymentID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:PaymentID>
</xpath>
<xpath expr="(//*[local-name()='PaymentMeans']/*[local-name()='InstructionNote'])" position="replace">
<cbc:InstructionNote xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:InstructionNote>
</xpath>
'''
@classmethod
def _get_or_create_state(cls, name, code, country):
"""Ensure a state exists for the given country."""
state = cls.env['res.country.state'].search([
('code', '=', code),
('country_id', '=', country.id)
], limit=1)
if not state:
state = cls.env['res.country.state'].create({
'name': name,
'code': code,
'country_id': country.id
})
return state
# Helper methods for creating documents
def _create_invoice(
self,
name="",
move_type="out_invoice",
company_id=None,
partner_id=None,
invoice_date='2025-01-01',
invoice_date_due='2025-01-01',
currency_id=None,
invoice_line_ids=[]):
"""
Create a draft invoice with the given parameters.
"""
def _create_invoice_line(line):
vals = {
'price_unit': line.get('price_unit', 0.0),
'quantity': line.get('quantity', 1),
'tax_ids': line.get('tax_ids', []),
}
if product_id := line.get('product_id'):
vals['product_id'] = product_id
if name := line.get('name'):
vals['name'] = name
return Command.create(vals)
vals = {
'name': name,
'move_type': move_type,
'company_id': (company_id or self.company).id,
'partner_id': partner_id.id,
'invoice_date': invoice_date,
'invoice_date_due': invoice_date_due,
'currency_id': (currency_id or self.company.currency_id).id,
'invoice_line_ids': [
_create_invoice_line(line) for line in invoice_line_ids
],
}
return self.env['account.move'].create(vals)
def _create_debit_note(
self,
name="",
move_type="out_invoice",
company_id=None,
partner_id=None,
invoice_date='2025-01-01',
invoice_date_due='2025-01-01',
currency_id=None,
invoice_line_ids=[],
reason="Totes Forgot"):
"""
Create a draft debit note from the given invoice values.
"""
# Create and post the original invoice
invoice = self._create_invoice(
name=name,
move_type=move_type,
company_id=company_id,
partner_id=partner_id,
invoice_date=invoice_date,
invoice_date_due=invoice_date_due,
currency_id=currency_id,
invoice_line_ids=invoice_line_ids)
invoice.action_post()
# Create debit note via wizard
debit_note_wizard = self.env['account.debit.note'].with_context({
'active_ids': [invoice.id],
'active_model': 'account.move',
'default_copy_lines': True
}).create({
'reason': reason,
})
res = debit_note_wizard.create_debit()
return self.env['account.move'].browse(res.get('res_id', []))
def _create_credit_note(
self,
name="",
move_type="out_invoice",
company_id=None,
partner_id=None,
invoice_date='2025-01-01',
invoice_date_due='2025-01-01',
currency_id=None,
invoice_line_ids=[],
reason='Totes Forgot'):
"""
Create a draft credit note from the given invoice values.
"""
# Create and post the original invoice
invoice = self._create_invoice(
name=name,
move_type=move_type,
company_id=company_id,
partner_id=partner_id,
invoice_date=invoice_date,
invoice_date_due=invoice_date_due,
currency_id=currency_id,
invoice_line_ids=invoice_line_ids)
invoice.action_post()
# Create credit note via reversal wizard
move_reversal = self.env['account.move.reversal'].with_context({
'active_model': 'account.move',
'active_ids': invoice.ids
}).create({
'reason': reason,
'journal_id': invoice.journal_id.id,
})
reversal = move_reversal.reverse_moves()
return self.env['account.move'].browse(reversal['res_id'])