Odoo18-Base/addons/l10n_es_edi_tbai/models/account_move.py
2025-01-06 10:57:38 +07:00

361 lines
16 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from markupsafe import Markup
from psycopg2.errors import LockNotAvailable
from odoo import _, api, fields, models
from odoo.exceptions import UserError
TBAI_REFUND_REASONS = [
('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"),
]
class AccountMove(models.Model):
_inherit = 'account.move'
l10n_es_tbai_state = fields.Selection([
('to_send', 'To Send'),
('sent', 'Sent'),
('cancelled', 'Cancelled'),
],
string='TicketBAI status',
compute='_compute_l10n_es_tbai_state',
)
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",
related='l10n_es_tbai_post_document_id.chain_index',
)
l10n_es_tbai_post_document_id = fields.Many2one(
comodel_name='l10n_es_edi_tbai.document',
readonly=True,
copy=False,
)
l10n_es_tbai_cancel_document_id = fields.Many2one(
comodel_name='l10n_es_edi_tbai.document',
readonly=True,
copy=False,
)
l10n_es_tbai_post_file = fields.Binary(
string="TicketBAI Post File",
related='l10n_es_tbai_post_document_id.xml_attachment_id.datas',
)
l10n_es_tbai_post_file_name = fields.Char(
string="TicketBAI Post Attachment Name",
related="l10n_es_tbai_post_document_id.xml_attachment_id.name",
)
l10n_es_tbai_cancel_file = fields.Binary(
string="TicketBAI Cancel File",
related='l10n_es_tbai_cancel_document_id.xml_attachment_id.datas',
)
l10n_es_tbai_cancel_file_name = fields.Char(
string="TicketBAI Cancel File Name",
related='l10n_es_tbai_cancel_document_id.xml_attachment_id.name',
)
l10n_es_tbai_is_required = fields.Boolean(
string="TicketBAI required",
help="Is the Basque EDI (TicketBAI) needed ?",
compute='_compute_l10n_es_tbai_is_required',
)
l10n_es_tbai_refund_reason = fields.Selection(
selection=TBAI_REFUND_REASONS,
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,
)
l10n_es_tbai_reversed_ids = fields.Many2many(
'account.move', 'account_move_tbai_reversed_moves', 'refund_id', 'reversed_move_id',
string="Refunded Vendor Bills",
domain="[('move_type', '=', 'in_invoice'), ('commercial_partner_id', '=', commercial_partner_id)]",
help="In the case where a vendor refund has multiple original invoices, you can set them here. ",
)
# -------------------------------------------------------------------------
# API-DECORATED & EXTENDED METHODS
# -------------------------------------------------------------------------
@api.depends('l10n_es_tbai_post_document_id.state', 'l10n_es_tbai_cancel_document_id.state')
def _compute_l10n_es_tbai_state(self):
for move in self:
state = 'to_send' if move.l10n_es_tbai_is_required else None
if move.l10n_es_tbai_post_document_id and move.l10n_es_tbai_post_document_id.state == 'accepted':
state = 'sent'
if move.l10n_es_tbai_cancel_document_id and move.l10n_es_tbai_cancel_document_id.state == 'accepted':
state = 'cancelled'
move.l10n_es_tbai_state = state
@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.company_id.l10n_es_tbai_is_enabled
and (
move.is_sale_document()
or move.is_purchase_document() and move.company_id.l10n_es_tbai_tax_agency == 'bizkaia'
)
and any(not line._l10n_es_tbai_is_ignored() for line in move.invoice_line_ids)
)
@api.depends('l10n_es_tbai_post_document_id.chain_index')
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 move.l10n_es_tbai_state != 'cancelled':
# NOTE this last condition (state is cancelled) is there because
# button_cancel calls button_draft.
# 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_check_can_send(self):
# Ensure the move is posted
if self.state != 'posted':
return _("Cannot send an entry that is not posted to TicketBAI.")
if self.l10n_es_tbai_state in ('sent', 'cancelled'):
return _("This entry has already been posted.")
def _l10n_es_tbai_get_attachment_name(self, cancel=False):
return self.name + ('_post.xml' if not cancel else '_cancel.xml')
def _l10n_es_tbai_create_edi_document(self, cancel=False):
return self.env['l10n_es_edi_tbai.document'].sudo().create({
'name': self.name,
'date': self.date,
'company_id': self.company_id.id,
'is_cancel': cancel,
})
def _l10n_es_tbai_post_document_in_chatter(self, message, cancel=False):
test_suffix = '(test mode)' if self.company_id.l10n_es_tbai_test_env else ''
self.with_context(no_new_invoice=True).message_post(
body=Markup("<pre>TicketBAI: posted {document_type} XML {test_suffix}\n{message}</pre>").format(
document_type='emission' if not cancel else 'cancellation',
test_suffix=test_suffix,
message=message,
),
attachment_ids=[self.l10n_es_tbai_post_document_id.xml_attachment_id.id] if not cancel else [self.l10n_es_tbai_cancel_document_id.xml_attachment_id.id],
)
def _l10n_es_tbai_lock_move(self):
""" Acquire a write lock on the invoices in self. """
self.ensure_one()
try:
with self.env.cr.savepoint(flush=False):
self.env.cr.execute('SELECT * FROM account_move WHERE id = %s FOR UPDATE NOWAIT', [self.id])
except LockNotAvailable:
raise UserError(_('Cannot send this entry as it is already being processed.'))
# -------------------------------------------------------------------------
# WEB SERVICE CALLS
# -------------------------------------------------------------------------
def l10n_es_tbai_send_bill(self):
for bill in self:
error = bill._l10n_es_tbai_post()
if self.env['account.move.send']._can_commit():
self._cr.commit()
if error:
raise UserError(error)
def l10n_es_tbai_cancel(self):
for invoice in self:
invoice._l10n_es_tbai_lock_move()
if invoice.l10n_es_tbai_cancel_document_id and invoice.l10n_es_tbai_cancel_document_id.state == 'rejected':
invoice.l10n_es_tbai_cancel_document_id.sudo().unlink()
if not invoice.l10n_es_tbai_cancel_document_id:
invoice.l10n_es_tbai_cancel_document_id = invoice._l10n_es_tbai_create_edi_document(cancel=True)
edi_document = invoice.l10n_es_tbai_cancel_document_id
error = edi_document._post_to_web_service(invoice._l10n_es_tbai_get_values(cancel=True))
if error:
raise UserError(error)
if edi_document.state == 'accepted':
invoice.button_cancel()
invoice._l10n_es_tbai_post_document_in_chatter(edi_document.response_message, cancel=True)
if self.env['account.move.send']._can_commit():
self._cr.commit()
if edi_document.state != 'accepted':
raise UserError(edi_document.response_message)
def _l10n_es_tbai_post(self):
self.ensure_one()
# Avoid the move to be sent if it is being modified by a parallel transaction (for example reset to draft)
# It will also avoid the move to be sent by different parallel transactions
self._l10n_es_tbai_lock_move()
error = self._l10n_es_tbai_check_can_send()
if error:
return error
if self.l10n_es_tbai_post_document_id and self.l10n_es_tbai_post_document_id.state == 'rejected':
self.l10n_es_tbai_post_document_id.sudo().unlink()
if not self.l10n_es_tbai_post_document_id:
self.l10n_es_tbai_post_document_id = self._l10n_es_tbai_create_edi_document()
edi_document = self.l10n_es_tbai_post_document_id
error = edi_document._post_to_web_service(self._l10n_es_tbai_get_values())
if error:
return error
if edi_document.state == 'accepted':
self._l10n_es_tbai_post_document_in_chatter(edi_document.response_message)
return
# Return the error message if the xml document was not accepted
return edi_document.response_message
# -------------------------------------------------------------------------
# XML DOCUMENT
# -------------------------------------------------------------------------
def _l10n_es_tbai_get_values(self, cancel=False):
values = {
'is_sale': self.is_sale_document(),
'partner': self.commercial_partner_id,
'is_simplified': self.l10n_es_is_simplified,
'delivery_date': self.delivery_date if self.delivery_date and self.delivery_date != self.invoice_date else None,
**self._l10n_es_tbai_get_attachment_values(cancel),
}
if values['is_sale']:
values.update(self._l10n_es_tbai_get_invoice_values(cancel=cancel))
elif self.company_id.l10n_es_tbai_tax_agency == 'bizkaia':
values.update(self._l10n_es_tbai_get_vendor_bill_values_batuz())
return values
def _l10n_es_tbai_get_attachment_values(self, cancel=False):
return {
'attachment_name': self._l10n_es_tbai_get_attachment_name(cancel=cancel),
'res_model': 'account.move',
'res_id': self.id,
}
def _l10n_es_tbai_get_invoice_values(self, cancel=False):
self.ensure_one()
base_amls = self.line_ids.filtered(lambda x: x.display_type == 'product')
base_lines = [self._prepare_product_base_line_for_taxes_computation(x) for x in base_amls]
for base_line in base_lines:
base_line['name'] = base_line['record'].name
tax_amls = self.line_ids.filtered(lambda x: x.display_type == 'tax')
tax_lines = [self._prepare_tax_line_for_taxes_computation(x) for x in tax_amls]
self.env['l10n_es_edi_tbai.document']._add_base_lines_tax_amounts(base_lines, self.company_id, tax_lines=tax_lines)
taxes = self.invoice_line_ids.tax_ids.flatten_taxes_hierarchy()
is_oss = any(tax._l10n_es_get_regime_code() == '17' for tax in taxes)
return {
**self._l10n_es_tbai_get_credit_note_values(),
'origin': self.invoice_origin and self.invoice_origin[:250] or 'manual',
'taxes': taxes,
'rate': abs(self.amount_total / self.amount_total_signed) if self.amount_total else 1,
'base_lines': base_lines,
'nosujeto_causa': 'IE' if is_oss else 'RL',
**({'post_doc': self.l10n_es_tbai_post_document_id} if cancel else {}),
}
def _l10n_es_tbai_get_credit_note_values(self):
return {
'is_refund': self.move_type == 'out_refund',
'refund_reason': self.l10n_es_tbai_refund_reason,
'refunded_doc': self.reversed_entry_id.l10n_es_tbai_post_document_id,
'refunded_doc_invoice_date': self.reversed_entry_id.invoice_date if self.reversed_entry_id else False,
}
def _l10n_es_tbai_get_vendor_bill_values_batuz(self):
""" For the vendor bills for Bizkaia, the structure is different than the regular Ticketbai XML (LROE)"""
values = {
'ref': self.ref,
'is_refund': self.move_type == 'in_refund',
'invoice_date': self.invoice_date,
'tipofactura': 'F5' if self._l10n_es_is_dua() else 'F1',
**self._l10n_es_tbai_get_vendor_bill_tax_values(),
}
# Check if intracom
mod_303_10 = self.env.ref('l10n_es.mod_303_casilla_10_balance')._get_matching_tags()
mod_303_11 = self.env.ref('l10n_es.mod_303_casilla_11_balance')._get_matching_tags()
tax_tags = self.invoice_line_ids.tax_ids.flatten_taxes_hierarchy().repartition_line_ids.tag_ids
intracom = bool(tax_tags & (mod_303_10 + mod_303_11))
values['regime_key'] = ['09'] if intracom else ['01']
# Credit notes (factura rectificativa)
if values['is_refund']:
values['refund_reason'] = self.l10n_es_tbai_refund_reason
values['credit_note_invoices'] = self.reversed_entry_id | self.l10n_es_tbai_reversed_ids
return values
def _l10n_es_tbai_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
if ((tax := line.tax_line_id) 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}
def _refunds_origin_required(self):
if self.l10n_es_tbai_is_required:
return True
return super()._refunds_origin_required()