Odoo18-Base/addons/l10n_es_edi_tbai/models/account_move.py

279 lines
13 KiB
Python
Raw Permalink Normal View History

2025-03-10 11:12:23 +07:00
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from base64 import b64decode, b64encode
from datetime import datetime
from re import sub as regex_sub
from collections import defaultdict
from lxml import etree
from odoo import _, api, fields, models
from odoo.addons.l10n_es_edi_tbai.models.l10n_es_edi_tbai_agencies import get_key
from odoo.exceptions import UserError
L10N_ES_TBAI_CRC8_TABLE = [
0x00, 0x07, 0x0E, 0x09, 0x1C, 0x1B, 0x12, 0x15, 0x38, 0x3F, 0x36, 0x31, 0x24, 0x23, 0x2A, 0x2D,
0x70, 0x77, 0x7E, 0x79, 0x6C, 0x6B, 0x62, 0x65, 0x48, 0x4F, 0x46, 0x41, 0x54, 0x53, 0x5A, 0x5D,
0xE0, 0xE7, 0xEE, 0xE9, 0xFC, 0xFB, 0xF2, 0xF5, 0xD8, 0xDF, 0xD6, 0xD1, 0xC4, 0xC3, 0xCA, 0xCD,
0x90, 0x97, 0x9E, 0x99, 0x8C, 0x8B, 0x82, 0x85, 0xA8, 0xAF, 0xA6, 0xA1, 0xB4, 0xB3, 0xBA, 0xBD,
0xC7, 0xC0, 0xC9, 0xCE, 0xDB, 0xDC, 0xD5, 0xD2, 0xFF, 0xF8, 0xF1, 0xF6, 0xE3, 0xE4, 0xED, 0xEA,
0xB7, 0xB0, 0xB9, 0xBE, 0xAB, 0xAC, 0xA5, 0xA2, 0x8F, 0x88, 0x81, 0x86, 0x93, 0x94, 0x9D, 0x9A,
0x27, 0x20, 0x29, 0x2E, 0x3B, 0x3C, 0x35, 0x32, 0x1F, 0x18, 0x11, 0x16, 0x03, 0x04, 0x0D, 0x0A,
0x57, 0x50, 0x59, 0x5E, 0x4B, 0x4C, 0x45, 0x42, 0x6F, 0x68, 0x61, 0x66, 0x73, 0x74, 0x7D, 0x7A,
0x89, 0x8E, 0x87, 0x80, 0x95, 0x92, 0x9B, 0x9C, 0xB1, 0xB6, 0xBF, 0xB8, 0xAD, 0xAA, 0xA3, 0xA4,
0xF9, 0xFE, 0xF7, 0xF0, 0xE5, 0xE2, 0xEB, 0xEC, 0xC1, 0xC6, 0xCF, 0xC8, 0xDD, 0xDA, 0xD3, 0xD4,
0x69, 0x6E, 0x67, 0x60, 0x75, 0x72, 0x7B, 0x7C, 0x51, 0x56, 0x5F, 0x58, 0x4D, 0x4A, 0x43, 0x44,
0x19, 0x1E, 0x17, 0x10, 0x05, 0x02, 0x0B, 0x0C, 0x21, 0x26, 0x2F, 0x28, 0x3D, 0x3A, 0x33, 0x34,
0x4E, 0x49, 0x40, 0x47, 0x52, 0x55, 0x5C, 0x5B, 0x76, 0x71, 0x78, 0x7F, 0x6A, 0x6D, 0x64, 0x63,
0x3E, 0x39, 0x30, 0x37, 0x22, 0x25, 0x2C, 0x2B, 0x06, 0x01, 0x08, 0x0F, 0x1A, 0x1D, 0x14, 0x13,
0xAE, 0xA9, 0xA0, 0xA7, 0xB2, 0xB5, 0xBC, 0xBB, 0x96, 0x91, 0x98, 0x9F, 0x8A, 0x8D, 0x84, 0x83,
0xDE, 0xD9, 0xD0, 0xD7, 0xC2, 0xC5, 0xCC, 0xCB, 0xE6, 0xE1, 0xE8, 0xEF, 0xFA, 0xFD, 0xF4, 0xF3
]
class AccountMove(models.Model):
_inherit = 'account.move'
# Stored fields
l10n_es_tbai_chain_index = fields.Integer(
string="TicketBAI chain index",
help="Invoice index in chain, set if and only if an in-chain XML was submitted and did not error",
copy=False, readonly=True,
)
# Stored XML Binaries
l10n_es_tbai_post_xml = fields.Binary(
attachment=True, readonly=True, copy=False,
string="Submission XML",
help="Submission XML sent to TicketBAI. Kept if accepted or no response (timeout), cleared otherwise.",
)
l10n_es_tbai_cancel_xml = fields.Binary(
attachment=True, readonly=True, copy=False,
string="Cancellation XML",
help="Cancellation XML sent to TicketBAI. Kept if accepted or no response (timeout), cleared otherwise.",
)
# Non-stored fields
l10n_es_tbai_is_required = fields.Boolean(
string="TicketBAI required",
help="Is the Basque EDI (TicketBAI) needed ?",
compute="_compute_l10n_es_tbai_is_required",
)
# Optional fields
l10n_es_tbai_refund_reason = fields.Selection(
selection=[
('R1', "R1: Art. 80.1, 80.2, 80.6 and rights founded error"),
('R2', "R2: Art. 80.3"),
('R3', "R3: Art. 80.4"),
('R4', "R4: Art. 80 - other"),
('R5', "R5: Factura rectificativa en facturas simplificadas"),
],
string="Invoice Refund Reason Code (TicketBai)",
help="BOE-A-1992-28740. Ley 37/1992, de 28 de diciembre, del Impuesto sobre el "
"Valor Añadido. Artículo 80. Modificación de la base imponible.",
copy=False,
)
# -------------------------------------------------------------------------
# API-DECORATED & EXTENDED METHODS
# -------------------------------------------------------------------------
@api.depends('move_type', 'company_id')
def _compute_l10n_es_tbai_is_required(self):
for move in self:
move.l10n_es_tbai_is_required = (move.is_sale_document() or move.is_purchase_document() and move.company_id.l10n_es_tbai_tax_agency == 'bizkaia'
and not any(t.l10n_es_type == 'ignore' for t in move.invoice_line_ids.tax_ids))\
and move.country_code == 'ES' \
and move.company_id.l10n_es_tbai_tax_agency
@api.depends('state', 'edi_document_ids.state')
def _compute_show_reset_to_draft_button(self):
# EXTENDS account_edi account.move
super()._compute_show_reset_to_draft_button()
for move in self:
if move.l10n_es_tbai_chain_index:
move.show_reset_to_draft_button = False
def button_draft(self):
# EXTENDS account account.move
for move in self:
if move.l10n_es_tbai_chain_index and not move.edi_state == 'cancelled':
# NOTE this last condition (state is cancelled) is there because
# _postprocess_cancel_edi_results calls button_draft before
# calling button_cancel. Draft button does not appear for user.
raise UserError(_("You cannot reset to draft an entry that has been posted to TicketBAI's chain"))
super().button_draft()
@api.ondelete(at_uninstall=False)
def _l10n_es_tbai_unlink_except_in_chain(self):
# Prevent deleting moves that are part of the TicketBAI chain
if not self._context.get('force_delete') and any(m.l10n_es_tbai_chain_index for m in self):
raise UserError(_('You cannot delete a move that has a TicketBAI chain id.'))
# -------------------------------------------------------------------------
# HELPER METHODS
# -------------------------------------------------------------------------
def _l10n_es_tbai_is_in_chain(self):
"""
True iff invoice has been posted to the chain and confirmed by govt.
Note that cancelled invoices remain part of the chain.
"""
tbai_doc_ids = self.edi_document_ids.filtered(lambda d: d.edi_format_id.code == 'es_tbai')
return self.l10n_es_tbai_is_required \
and len(tbai_doc_ids) > 0 \
and not any(tbai_doc_ids.filtered(lambda d: d.state == 'to_send'))
def _get_l10n_es_tbai_sequence_and_number(self):
"""Get the TicketBAI sequence a number values for this invoice."""
self.ensure_one()
if self.is_purchase_document(): # Batuz
# Check if we are cancelling or not
doc = self.env['account.edi.document'].search([('state', '=', 'to_cancel'),
('edi_format_id.code', '=', 'es_tbai')], limit=1)
if doc and self.l10n_es_tbai_post_xml:
vals = self._get_l10n_es_tbai_values_from_xml({
'sequence': './/CabeceraFactura/SerieFactura',
'number': './/CabeceraFactura/NumFactura',
})
if vals['sequence'] and vals['number']:
return vals['sequence'], vals['number']
number = self.ref
sequence = "TEST" if self.company_id.l10n_es_edi_test_env else ""
else:
sequence = self.sequence_prefix.rstrip('/')
# NOTE non-decimal characters should not appear in the number
seq_length = self._get_sequence_format_param(self.name)[1]['seq_length']
number = f"{self.sequence_number:0{seq_length}d}"
sequence = regex_sub(r"[^0-9A-Za-z.\_\-\/]", "", sequence) # remove forbidden characters
sequence = regex_sub(r"\s+", " ", sequence) # no more than one consecutive whitespace allowed
# NOTE (optional) not recommended to use chars out of ([0123456789ABCDEFGHJKLMNPQRSTUVXYZ.\_\-\/ ])
sequence += "TEST" if self.company_id.l10n_es_edi_test_env else ""
return sequence, number
def _get_l10n_es_tbai_signature_and_date(self):
"""
Get the TicketBAI signature and registration date for this invoice.
Values are read directly from the 'post' XMLs submitted to the government \
(the 'cancel' XML is ignored).
The registration date is the date the invoice was registered into the govt's TicketBAI servers.
"""
self.ensure_one()
vals = self._get_l10n_es_tbai_values_from_xml({
'signature': r'.//{http://www.w3.org/2000/09/xmldsig#}SignatureValue',
'registration_date': r'.//CabeceraFactura//FechaExpedicionFactura'
})
# RFC2045 - Base64 Content-Transfer-Encoding (page 25)
# Any characters outside of the base64 alphabet are to be ignored in base64-encoded data.
signature = vals['signature'].replace("\n", "")
registration_date = datetime.strptime(vals['registration_date'], '%d-%m-%Y')
return signature, registration_date
def _get_l10n_es_tbai_id(self):
"""Get the TicketBAI ID (TBAID) as defined in the TicketBAI doc."""
self.ensure_one()
if not self._l10n_es_tbai_is_in_chain():
return ''
signature, registration_date = self._get_l10n_es_tbai_signature_and_date()
company = self.company_id
tbai_id_no_crc = '-'.join([
'TBAI',
str(company.vat[2:] if company.vat.startswith('ES') else company.vat),
datetime.strftime(registration_date, '%d%m%y'),
signature[:13],
'' # CRC
])
return tbai_id_no_crc + self._l10n_es_edi_tbai_crc8(tbai_id_no_crc)
def _get_l10n_es_tbai_qr(self):
"""Returns the URL for the invoice's QR code. We can not use url_encode because it escapes / e.g."""
self.ensure_one()
if not self._l10n_es_tbai_is_in_chain():
return ''
company = self.company_id
sequence, number = self._get_l10n_es_tbai_sequence_and_number()
tbai_qr_no_crc = get_key(company.l10n_es_tbai_tax_agency, 'qr_url_', company.l10n_es_edi_test_env) + '?' + '&'.join([
'id=' + self._get_l10n_es_tbai_id(),
's=' + sequence,
'nf=' + number,
'i=' + self._get_l10n_es_tbai_values_from_xml({'importe': r'.//ImporteTotalFactura'})['importe']
])
qr_url = tbai_qr_no_crc + '&cr=' + self._l10n_es_edi_tbai_crc8(tbai_qr_no_crc)
return qr_url
def _l10n_es_edi_tbai_crc8(self, data):
crc = 0x0
for c in data:
crc = L10N_ES_TBAI_CRC8_TABLE[(crc ^ ord(c)) & 0xFF]
return '{:03d}'.format(crc & 0xFF)
def _get_l10n_es_tbai_values_from_xml(self, xpaths):
"""
This function reads values directly from the 'post' XML submitted to the government \
(the 'cancel' XML is ignored).
"""
res = dict.fromkeys(xpaths, '')
doc_xml = self._get_l10n_es_tbai_submitted_xml()
if doc_xml is None:
return res
for key, value in xpaths.items():
res[key] = doc_xml.find(value).text
return res
def _get_l10n_es_tbai_submitted_xml(self, cancel=False):
"""Returns the XML object representing the post or cancel document."""
self.ensure_one()
self = self.with_context(bin_size=False)
doc = self.l10n_es_tbai_cancel_xml if cancel else self.l10n_es_tbai_post_xml
if not doc:
return None
return etree.fromstring(b64decode(doc))
def _update_l10n_es_tbai_submitted_xml(self, xml_doc, cancel):
"""Updates the binary data of the post or cancel document, from its XML object."""
self.ensure_one()
b64_doc = b'' if xml_doc is None else b64encode(etree.tostring(xml_doc, encoding='UTF-8'))
if cancel:
self.l10n_es_tbai_cancel_xml = b64_doc
else:
self.l10n_es_tbai_post_xml = b64_doc
def _is_l10n_es_tbai_simplified(self):
return self.commercial_partner_id == self.env.ref("l10n_es_edi_sii.partner_simplified")
def _get_vendor_bill_tax_values(self):
self.ensure_one()
results = defaultdict(lambda: {'base_amount': 0.0, 'tax_amount': 0.0})
amount_total = 0.0
for line in self.line_ids.filtered(lambda l: l.display_type in ('product', 'tax')):
if any(t.l10n_es_type == 'ignore' for t in line.tax_ids) or line.tax_line_id.l10n_es_type == 'ignore':
continue
if line.tax_line_id.l10n_es_type != 'retencion':
amount_total += line.balance
for tax in line.tax_ids.filtered(lambda t: t.l10n_es_type not in ('recargo', 'retencion')):
results[tax]['base_amount'] += line.balance
tax = line.tax_line_id
if (tax and tax.l10n_es_type not in ('recargo', 'retencion') and
line.tax_repartition_line_id.factor_percent != -100.0):
results[tax]['tax_amount'] += line.balance
iva_values = []
for tax in results:
code = "C" # Bienes Corrientes
if tax.l10n_es_bien_inversion:
code = "I" # Investment Goods
if tax.tax_scope == 'service':
code = 'G' # Gastos
iva_values.append({'base': results[tax]['base_amount'],
'code': code,
'tax': results[tax]['tax_amount'],
'rec': tax})
return {'iva_values': iva_values,
'amount_total': amount_total}