1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355
|
# -*- 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,
'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}
|