382 lines
20 KiB
Python
382 lines
20 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
import io
|
|
import zipfile
|
|
from werkzeug.urls import url_encode
|
|
|
|
from odoo import api, fields, models, _
|
|
from odoo.exceptions import UserError
|
|
|
|
|
|
class AccountMove(models.Model):
|
|
_inherit = 'account.move'
|
|
|
|
edi_document_ids = fields.One2many(
|
|
comodel_name='account.edi.document',
|
|
inverse_name='move_id')
|
|
edi_state = fields.Selection(
|
|
selection=[('to_send', 'To Send'), ('sent', 'Sent'), ('to_cancel', 'To Cancel'), ('cancelled', 'Cancelled')],
|
|
string="Electronic invoicing",
|
|
store=True,
|
|
compute='_compute_edi_state',
|
|
help='The aggregated state of all the EDIs with web-service of this move')
|
|
edi_error_count = fields.Integer(
|
|
compute='_compute_edi_error_count',
|
|
help='How many EDIs are in error for this move?')
|
|
edi_blocking_level = fields.Selection(
|
|
selection=[('info', 'Info'), ('warning', 'Warning'), ('error', 'Error')],
|
|
compute='_compute_edi_error_message')
|
|
edi_error_message = fields.Html(
|
|
compute='_compute_edi_error_message')
|
|
# Technical field to display the documents that will be processed by the CRON
|
|
edi_web_services_to_process = fields.Text(
|
|
compute='_compute_edi_web_services_to_process')
|
|
edi_show_cancel_button = fields.Boolean(
|
|
compute='_compute_edi_show_cancel_button')
|
|
edi_show_abandon_cancel_button = fields.Boolean(
|
|
compute='_compute_edi_show_abandon_cancel_button')
|
|
|
|
@api.depends('edi_document_ids.state')
|
|
def _compute_edi_state(self):
|
|
for move in self:
|
|
all_states = set(move.edi_document_ids.filtered(lambda d: d.edi_format_id._needs_web_services()).mapped('state'))
|
|
if all_states == {'sent'}:
|
|
move.edi_state = 'sent'
|
|
elif all_states == {'cancelled'}:
|
|
move.edi_state = 'cancelled'
|
|
elif 'to_send' in all_states:
|
|
move.edi_state = 'to_send'
|
|
elif 'to_cancel' in all_states:
|
|
move.edi_state = 'to_cancel'
|
|
else:
|
|
move.edi_state = False
|
|
|
|
@api.depends('edi_document_ids.error')
|
|
def _compute_edi_error_count(self):
|
|
for move in self:
|
|
move.edi_error_count = len(move.edi_document_ids.filtered(lambda d: d.error))
|
|
|
|
@api.depends('edi_error_count', 'edi_document_ids.error', 'edi_document_ids.blocking_level')
|
|
def _compute_edi_error_message(self):
|
|
for move in self:
|
|
if move.edi_error_count == 0:
|
|
move.edi_error_message = None
|
|
move.edi_blocking_level = None
|
|
elif move.edi_error_count == 1:
|
|
error_doc = move.edi_document_ids.filtered(lambda d: d.error)
|
|
move.edi_error_message = error_doc.error
|
|
move.edi_blocking_level = error_doc.blocking_level
|
|
else:
|
|
error_levels = set([doc.blocking_level for doc in move.edi_document_ids])
|
|
if 'error' in error_levels:
|
|
move.edi_error_message = str(move.edi_error_count) + _(" Electronic invoicing error(s)")
|
|
move.edi_blocking_level = 'error'
|
|
elif 'warning' in error_levels:
|
|
move.edi_error_message = str(move.edi_error_count) + _(" Electronic invoicing warning(s)")
|
|
move.edi_blocking_level = 'warning'
|
|
else:
|
|
move.edi_error_message = str(move.edi_error_count) + _(" Electronic invoicing info(s)")
|
|
move.edi_blocking_level = 'info'
|
|
|
|
@api.depends(
|
|
'edi_document_ids',
|
|
'edi_document_ids.state',
|
|
'edi_document_ids.blocking_level',
|
|
'edi_document_ids.edi_format_id',
|
|
'edi_document_ids.edi_format_id.name')
|
|
def _compute_edi_web_services_to_process(self):
|
|
for move in self:
|
|
to_process = move.edi_document_ids.filtered(lambda d: d.state in ['to_send', 'to_cancel'] and d.blocking_level != 'error')
|
|
format_web_services = to_process.edi_format_id.filtered(lambda f: f._needs_web_services())
|
|
move.edi_web_services_to_process = ', '.join(f.name for f in format_web_services)
|
|
|
|
@api.depends('edi_document_ids.state')
|
|
def _compute_show_reset_to_draft_button(self):
|
|
# OVERRIDE
|
|
super()._compute_show_reset_to_draft_button()
|
|
|
|
for move in self:
|
|
for doc in move.edi_document_ids:
|
|
move_applicability = doc.edi_format_id._get_move_applicability(move)
|
|
if doc.edi_format_id._needs_web_services() \
|
|
and doc.state in ('sent', 'to_cancel') \
|
|
and move_applicability \
|
|
and move_applicability.get('cancel'):
|
|
move.show_reset_to_draft_button = False
|
|
break
|
|
|
|
@api.depends('edi_document_ids.state')
|
|
def _compute_edi_show_cancel_button(self):
|
|
for move in self:
|
|
if move.state != 'posted':
|
|
move.edi_show_cancel_button = False
|
|
continue
|
|
|
|
move.edi_show_cancel_button = False
|
|
for doc in move.edi_document_ids:
|
|
move_applicability = doc.edi_format_id._get_move_applicability(move)
|
|
if doc.edi_format_id._needs_web_services() \
|
|
and doc.state == 'sent' \
|
|
and move_applicability \
|
|
and move_applicability.get('cancel'):
|
|
move.edi_show_cancel_button = True
|
|
break
|
|
|
|
@api.depends('edi_document_ids.state')
|
|
def _compute_edi_show_abandon_cancel_button(self):
|
|
for move in self:
|
|
move.edi_show_abandon_cancel_button = False
|
|
for doc in move.sudo().edi_document_ids:
|
|
move_applicability = doc.edi_format_id._get_move_applicability(move)
|
|
if doc.edi_format_id._needs_web_services() \
|
|
and doc.state == 'to_cancel' \
|
|
and move_applicability \
|
|
and move_applicability.get('cancel'):
|
|
move.edi_show_abandon_cancel_button = True
|
|
break
|
|
|
|
####################################################
|
|
# Export Electronic Document
|
|
####################################################
|
|
|
|
def _prepare_edi_tax_details(self, filter_to_apply=None, filter_invl_to_apply=None, grouping_key_generator=None):
|
|
''' Compute amounts related to taxes for the current invoice.
|
|
|
|
:param filter_to_apply: Optional filter to exclude some tax values from the final results.
|
|
The filter is defined as a method getting a dictionary as parameter
|
|
representing the tax values for a single repartition line.
|
|
This dictionary contains:
|
|
|
|
'base_line_id': An account.move.line record.
|
|
'tax_id': An account.tax record.
|
|
'tax_repartition_line_id': An account.tax.repartition.line record.
|
|
'base_amount': The tax base amount expressed in company currency.
|
|
'tax_amount': The tax amount expressed in company currency.
|
|
'base_amount_currency': The tax base amount expressed in foreign currency.
|
|
'tax_amount_currency': The tax amount expressed in foreign currency.
|
|
|
|
If the filter is returning False, it means the current tax values will be
|
|
ignored when computing the final results.
|
|
|
|
:param filter_invl_to_apply: Optional filter to exclude some invoice lines.
|
|
|
|
:param grouping_key_generator: Optional method used to group tax values together. By default, the tax values
|
|
are grouped by tax. This parameter is a method getting a dictionary as parameter
|
|
(same signature as 'filter_to_apply').
|
|
|
|
This method must returns a dictionary where values will be used to create the
|
|
grouping_key to aggregate tax values together. The returned dictionary is added
|
|
to each tax details in order to retrieve the full grouping_key later.
|
|
|
|
:param compute_mode: Optional parameter to specify the method used to allocate the tax line amounts
|
|
among the invoice lines:
|
|
'tax_details' (the default) uses the AccountMove._get_query_tax_details method.
|
|
'compute_all' uses the AccountTax._compute_all method.
|
|
|
|
The 'tax_details' method takes the tax line balance and allocates it among the
|
|
invoice lines to which that tax applies, proportionately to the invoice lines'
|
|
base amounts. This always ensures that the sum of the tax amounts equals the
|
|
tax line's balance, which, depending on the constraints of a particular
|
|
localization, can be more appropriate when 'Round Globally' is set.
|
|
|
|
The 'compute_all' method returns, for each invoice line, the exact tax amounts
|
|
corresponding to the taxes applied to the invoice line. Depending on the
|
|
constraints of the particular localization, this can be more appropriate when
|
|
'Round per Line' is set.
|
|
|
|
:return: The full tax details for the current invoice and for each invoice line
|
|
separately. The returned dictionary is the following:
|
|
|
|
'base_amount': The total tax base amount in company currency for the whole invoice.
|
|
'tax_amount': The total tax amount in company currency for the whole invoice.
|
|
'base_amount_currency': The total tax base amount in foreign currency for the whole invoice.
|
|
'tax_amount_currency': The total tax amount in foreign currency for the whole invoice.
|
|
'tax_details': A mapping of each grouping key (see 'grouping_key_generator') to a dictionary
|
|
containing:
|
|
|
|
'base_amount': The tax base amount in company currency for the current group.
|
|
'tax_amount': The tax amount in company currency for the current group.
|
|
'base_amount_currency': The tax base amount in foreign currency for the current group.
|
|
'tax_amount_currency': The tax amount in foreign currency for the current group.
|
|
'group_tax_details': The list of all tax values aggregated into this group.
|
|
|
|
'tax_details_per_record': A mapping of each invoice line to a dictionary containing:
|
|
|
|
'base_amount': The total tax base amount in company currency for the whole invoice line.
|
|
'tax_amount': The total tax amount in company currency for the whole invoice line.
|
|
'base_amount_currency': The total tax base amount in foreign currency for the whole invoice line.
|
|
'tax_amount_currency': The total tax amount in foreign currency for the whole invoice line.
|
|
'tax_details': A mapping of each grouping key (see 'grouping_key_generator') to a dictionary
|
|
containing:
|
|
|
|
'base_amount': The tax base amount in company currency for the current group.
|
|
'tax_amount': The tax amount in company currency for the current group.
|
|
'base_amount_currency': The tax base amount in foreign currency for the current group.
|
|
'tax_amount_currency': The tax amount in foreign currency for the current group.
|
|
'group_tax_details': The list of all tax values aggregated into this group.
|
|
|
|
'''
|
|
return self._prepare_invoice_aggregated_taxes(
|
|
filter_invl_to_apply=filter_invl_to_apply,
|
|
filter_tax_values_to_apply=filter_to_apply,
|
|
grouping_key_generator=grouping_key_generator,
|
|
)
|
|
|
|
def _is_ready_to_be_sent(self):
|
|
# OVERRIDE
|
|
# Prevent a mail to be sent to the customer if the EDI document is not sent.
|
|
res = super()._is_ready_to_be_sent()
|
|
|
|
if not res:
|
|
return False
|
|
|
|
edi_documents_to_send = self.edi_document_ids.filtered(lambda x: x.state == 'to_send')
|
|
return not bool(edi_documents_to_send)
|
|
|
|
def _post(self, soft=True):
|
|
# OVERRIDE
|
|
# Set the electronic document to be posted and post immediately for synchronous formats.
|
|
posted = super()._post(soft=soft)
|
|
|
|
edi_document_vals_list = []
|
|
for move in posted:
|
|
for edi_format in move.journal_id.edi_format_ids:
|
|
move_applicability = edi_format._get_move_applicability(move)
|
|
|
|
if move_applicability:
|
|
errors = edi_format._check_move_configuration(move)
|
|
if errors:
|
|
raise UserError(_("Invalid invoice configuration:\n\n%s", '\n'.join(errors)))
|
|
|
|
existing_edi_document = move.edi_document_ids.filtered(lambda x: x.edi_format_id == edi_format)
|
|
if existing_edi_document:
|
|
existing_edi_document.sudo().write({
|
|
'state': 'to_send',
|
|
'attachment_id': False,
|
|
})
|
|
else:
|
|
edi_document_vals_list.append({
|
|
'edi_format_id': edi_format.id,
|
|
'move_id': move.id,
|
|
'state': 'to_send',
|
|
})
|
|
|
|
self.env['account.edi.document'].create(edi_document_vals_list)
|
|
posted.edi_document_ids._process_documents_no_web_services()
|
|
self.env.ref('account_edi.ir_cron_edi_network')._trigger()
|
|
return posted
|
|
|
|
def button_cancel(self):
|
|
# OVERRIDE
|
|
# Set the electronic document to be canceled and cancel immediately for synchronous formats.
|
|
res = super().button_cancel()
|
|
|
|
self.edi_document_ids.filtered(lambda doc: doc.state != 'sent').write({'state': 'cancelled', 'error': False, 'blocking_level': False})
|
|
self.edi_document_ids.filtered(lambda doc: doc.state == 'sent').write({'state': 'to_cancel', 'error': False, 'blocking_level': False})
|
|
self.edi_document_ids._process_documents_no_web_services()
|
|
self.env.ref('account_edi.ir_cron_edi_network')._trigger()
|
|
|
|
return res
|
|
|
|
def button_draft(self):
|
|
# OVERRIDE
|
|
for move in self:
|
|
if move.edi_show_cancel_button:
|
|
raise UserError(_(
|
|
"You can't edit the following journal entry %s because an electronic document has already been "
|
|
"sent. Please use the 'Request EDI Cancellation' button instead.",
|
|
move.display_name))
|
|
|
|
res = super().button_draft()
|
|
|
|
self.edi_document_ids.write({'error': False, 'blocking_level': False})
|
|
self.edi_document_ids.filtered(lambda doc: doc.state == 'to_send').unlink()
|
|
|
|
return res
|
|
|
|
def button_cancel_posted_moves(self):
|
|
'''Mark the edi.document related to this move to be canceled.
|
|
'''
|
|
to_cancel_documents = self.env['account.edi.document']
|
|
for move in self:
|
|
move._check_fiscalyear_lock_date()
|
|
is_move_marked = False
|
|
for doc in move.edi_document_ids:
|
|
move_applicability = doc.edi_format_id._get_move_applicability(move)
|
|
if doc.edi_format_id._needs_web_services() \
|
|
and doc.state == 'sent' \
|
|
and move_applicability \
|
|
and move_applicability.get('cancel'):
|
|
to_cancel_documents |= doc
|
|
is_move_marked = True
|
|
if is_move_marked:
|
|
move.message_post(body=_("A cancellation of the EDI has been requested."))
|
|
|
|
to_cancel_documents.write({'state': 'to_cancel', 'error': False, 'blocking_level': False})
|
|
|
|
def button_abandon_cancel_posted_posted_moves(self):
|
|
'''Cancel the request for cancellation of the EDI.
|
|
'''
|
|
documents = self.env['account.edi.document']
|
|
for move in self:
|
|
is_move_marked = False
|
|
for doc in move.edi_document_ids:
|
|
move_applicability = doc.edi_format_id._get_move_applicability(move)
|
|
if doc.state == 'to_cancel' and move_applicability and move_applicability.get('cancel'):
|
|
documents |= doc
|
|
is_move_marked = True
|
|
if is_move_marked:
|
|
move.message_post(body=_("A request for cancellation of the EDI has been called off."))
|
|
|
|
documents.write({'state': 'sent'})
|
|
|
|
def _get_edi_document(self, edi_format):
|
|
return self.edi_document_ids.filtered(lambda d: d.edi_format_id == edi_format)
|
|
|
|
def _get_edi_attachment(self, edi_format):
|
|
return self._get_edi_document(edi_format).sudo().attachment_id
|
|
|
|
# this override is to make sure that the main attachment is not the edi xml otherwise the attachment viewer will not work correctly
|
|
def _message_set_main_attachment_id(self, attachment_ids):
|
|
if self.message_main_attachment_id and len(attachment_ids) > 1 and self.message_main_attachment_id in self.edi_document_ids.attachment_id:
|
|
self.message_main_attachment_id = self.env['ir.attachment']
|
|
super()._message_set_main_attachment_id(attachment_ids)
|
|
|
|
####################################################
|
|
# Business operations
|
|
####################################################
|
|
|
|
def button_process_edi_web_services(self):
|
|
self.action_process_edi_web_services(with_commit=False)
|
|
|
|
def action_process_edi_web_services(self, with_commit=True):
|
|
docs = self.edi_document_ids.filtered(lambda d: d.state in ('to_send', 'to_cancel') and d.blocking_level != 'error')
|
|
docs._process_documents_web_services(with_commit=with_commit)
|
|
|
|
def _retry_edi_documents_error_hook(self):
|
|
''' Hook called when edi_documents are retried. For example, when it's needed to clean a field.
|
|
TO OVERRIDE
|
|
'''
|
|
return
|
|
|
|
def action_retry_edi_documents_error(self):
|
|
self._retry_edi_documents_error_hook()
|
|
self.edi_document_ids.write({'error': False, 'blocking_level': False})
|
|
self.action_process_edi_web_services()
|
|
|
|
####################################################
|
|
# Mailing
|
|
####################################################
|
|
|
|
def _process_attachments_for_template_post(self, mail_template):
|
|
""" Add Edi attachments to templates. """
|
|
result = super()._process_attachments_for_template_post(mail_template)
|
|
for move in self.filtered('edi_document_ids'):
|
|
move_result = result.setdefault(move.id, {})
|
|
for edi_doc in move.edi_document_ids:
|
|
edi_attachments = edi_doc._filter_edi_attachments_for_mailing()
|
|
move_result.setdefault('attachment_ids', []).extend(edi_attachments.get('attachment_ids', []))
|
|
move_result.setdefault('attachments', []).extend(edi_attachments.get('attachments', []))
|
|
return result
|