[IMP] account_edi: added error_level + better management of Lock on account.move
- Some errors will never get fixed until user intervention, it doesn't make sense to run the CRON when there is such error. + some small improvements and esthetic changes - When we are in a CRON, we need to commit the changes between each call to web-service to avoid loss of data. - Small refactor of edi.document prepare_jobs and process_jobs - Added an arbitrary key to create the batches.
This commit is contained in:
@@ -308,6 +308,11 @@ msgstr ""
|
||||
msgid "State"
|
||||
msgstr ""
|
||||
|
||||
#. module: account_edi
|
||||
#: model:ir.model.fields,help:account_edi_extended.field_account_edi_document__error
|
||||
msgid "The text of the last error that happened during Electronic Invoice operation."
|
||||
msgstr ""
|
||||
|
||||
#. module: account_edi
|
||||
#: model:ir.model.fields,help:account_edi.field_account_bank_statement_line__edi_web_services_to_process
|
||||
#: model:ir.model.fields,help:account_edi.field_account_move__edi_web_services_to_process
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo import models, fields, api
|
||||
from odoo.addons.account_edi_extended.models.account_edi_document import DEFAULT_BLOCKING_LEVEL
|
||||
from psycopg2 import OperationalError
|
||||
import logging
|
||||
|
||||
@@ -17,7 +18,7 @@ class AccountEdiDocument(models.Model):
|
||||
edi_format_id = fields.Many2one('account.edi.format', required=True)
|
||||
attachment_id = fields.Many2one('ir.attachment', help='The file generated by edi_format_id when the invoice is posted (and this document is processed).')
|
||||
state = fields.Selection([('to_send', 'To Send'), ('sent', 'Sent'), ('to_cancel', 'To Cancel'), ('cancelled', 'Cancelled')])
|
||||
error = fields.Html()
|
||||
error = fields.Html(help='The text of the last error that happened during Electronic Invoice operation.')
|
||||
|
||||
# == Not stored fields ==
|
||||
name = fields.Char(related='attachment_id.name')
|
||||
@@ -31,24 +32,32 @@ class AccountEdiDocument(models.Model):
|
||||
),
|
||||
]
|
||||
|
||||
def write(self, vals):
|
||||
''' If account_edi_extended is not installed, a default behaviour is used instead.
|
||||
'''
|
||||
if 'blocking_level' in vals and 'blocking_level' not in self.env['account.edi.document']._fields:
|
||||
vals.pop('blocking_level')
|
||||
|
||||
return super().write(vals)
|
||||
|
||||
def _prepare_jobs(self):
|
||||
"""Creates a list of jobs to be performed by '_process_jobs' for the documents in self.
|
||||
"""Creates a list of jobs to be performed by '_process_job' for the documents in self.
|
||||
Each document represent a job, BUT if multiple documents have the same state, edi_format_id,
|
||||
doc_type (invoice or payment) and company_id AND the edi_format_id supports batching, they are grouped
|
||||
into a single job.
|
||||
|
||||
:returns: A list of tuples (key, documents)
|
||||
* key: A tuple (edi_format_id, state, doc_type, company_id)
|
||||
** edi_format_id: The format to perform the operation with
|
||||
** state: The state of the documents of this job
|
||||
** doc_type: Are the moves of this job invoice or payments ?
|
||||
** company_id: The company the moves belong to
|
||||
* documents: The documents related to this job. If edi_format_id does not support batch, length must be one
|
||||
:returns: A list of tuples (documents, doc_type)
|
||||
* documents: The documents related to this job. If edi_format_id does not support batch, length is one
|
||||
* doc_type: Are the moves of this job invoice or payments ?
|
||||
"""
|
||||
|
||||
to_process = []
|
||||
batches = {}
|
||||
for edi_doc in self.filtered(lambda d: d.state in ('to_send', 'to_cancel')):
|
||||
# Classify jobs by (edi_format, edi_doc.state, doc_type, move.company_id, custom_key)
|
||||
to_process = {}
|
||||
if 'blocking_level' in self.env['account.edi.document']._fields:
|
||||
documents = self.filtered(lambda d: d.state in ('to_send', 'to_cancel') and d.blocking_level != 'error')
|
||||
else:
|
||||
documents = self.filtered(lambda d: d.state in ('to_send', 'to_cancel'))
|
||||
for edi_doc in documents:
|
||||
move = edi_doc.move_id
|
||||
edi_format = edi_doc.edi_format_id
|
||||
if move.is_invoice(include_receipts=True):
|
||||
@@ -58,20 +67,64 @@ class AccountEdiDocument(models.Model):
|
||||
else:
|
||||
continue
|
||||
|
||||
key = (edi_format, edi_doc.state, doc_type, move.company_id)
|
||||
if edi_format._support_batching():
|
||||
if not batches.get(key, None):
|
||||
batches[key] = self.env['account.edi.document']
|
||||
batches[key] |= edi_doc
|
||||
else:
|
||||
to_process.append((key, edi_doc))
|
||||
to_process.extend(batches.items())
|
||||
return to_process
|
||||
custom_key = edi_format._get_batch_key(edi_doc.move_id, edi_doc.state)
|
||||
key = (edi_format, edi_doc.state, doc_type, move.company_id, custom_key)
|
||||
to_process.setdefault(key, self.env['account.edi.document'])
|
||||
to_process[key] |= edi_doc
|
||||
|
||||
# Order payments/invoice and create batches.
|
||||
invoices = []
|
||||
payments = []
|
||||
for key, documents in to_process.items():
|
||||
edi_format, state, doc_type, company_id, custom_key = key
|
||||
target = invoices if doc_type == 'invoice' else payments
|
||||
batch = self.env['account.edi.document']
|
||||
for doc in documents:
|
||||
if edi_format._support_batching(move=doc.move_id, state=state, company=company_id):
|
||||
batch |= doc
|
||||
else:
|
||||
target.append((doc, doc_type))
|
||||
if batch:
|
||||
target.append((batch, doc_type))
|
||||
return invoices + payments
|
||||
|
||||
@api.model
|
||||
def _convert_to_old_jobs_format(self, jobs):
|
||||
""" See '_prepare_jobs' :
|
||||
Old format : ((edi_format, state, doc_type, company_id), documents)
|
||||
Since edi_format, state and company_id can be deduced from documents, this is redundant and more prone to unexpected behaviours.
|
||||
New format : (doc_type, documents).
|
||||
|
||||
However, for backward compatibility of 'process_jobs', we need a way to convert back to the old format.
|
||||
"""
|
||||
return [(
|
||||
(documents.edi_format_id, documents[0].state, doc_type, documents.move_id.company_id),
|
||||
documents
|
||||
) for documents, doc_type in jobs]
|
||||
|
||||
@api.model
|
||||
def _process_jobs(self, to_process):
|
||||
""" Deprecated, use _process_job instead.
|
||||
|
||||
:param to_process: A list of tuples (key, documents)
|
||||
* key: A tuple (edi_format_id, state, doc_type, company_id)
|
||||
** edi_format_id: The format to perform the operation with
|
||||
** state: The state of the documents of this job
|
||||
** doc_type: Are the moves of this job invoice or payments ?
|
||||
** company_id: The company the moves belong to
|
||||
* documents: The documents related to this job. If edi_format_id does not support batch, length is one
|
||||
"""
|
||||
for key, documents in to_process:
|
||||
dummy, dummy, doc_type, dummy = key
|
||||
self._process_job(documents, doc_type)
|
||||
|
||||
@api.model
|
||||
def _process_job(self, documents, doc_type):
|
||||
"""Post or cancel move_id (invoice or payment) by calling the related methods on edi_format_id.
|
||||
Invoices are processed before payments.
|
||||
|
||||
:param documents: The documents related to this job. If edi_format_id does not support batch, length is one
|
||||
:param doc_type: Are the moves of this job invoice or payments ?
|
||||
"""
|
||||
def _postprocess_post_edi_results(documents, edi_result):
|
||||
attachments_to_unlink = self.env['ir.attachment']
|
||||
@@ -83,6 +136,7 @@ class AccountEdiDocument(models.Model):
|
||||
values = {
|
||||
'attachment_id': move_result['attachment'].id,
|
||||
'error': move_result.get('error', False),
|
||||
'blocking_level': move_result.get('blocking_level', DEFAULT_BLOCKING_LEVEL) if 'error' in move_result else False,
|
||||
}
|
||||
if not values.get('error'):
|
||||
values.update({'state': 'sent'})
|
||||
@@ -90,7 +144,10 @@ class AccountEdiDocument(models.Model):
|
||||
if not old_attachment.res_model or not old_attachment.res_id:
|
||||
attachments_to_unlink |= old_attachment
|
||||
else:
|
||||
document.error = move_result.get('error', _("Error when processing the journal entry."))
|
||||
document.write({
|
||||
'error': move_result.get('error', False),
|
||||
'blocking_level': move_result.get('blocking_level', DEFAULT_BLOCKING_LEVEL) if 'error' in move_result else False,
|
||||
})
|
||||
|
||||
# Attachments that are not explicitly linked to a business model could be removed because they are not
|
||||
# supposed to have any traceability from the user.
|
||||
@@ -102,12 +159,13 @@ class AccountEdiDocument(models.Model):
|
||||
for document in documents:
|
||||
move = document.move_id
|
||||
move_result = edi_result.get(move, {})
|
||||
if move_result.get('success'):
|
||||
if move_result.get('success') is True:
|
||||
old_attachment = document.attachment_id
|
||||
document.write({
|
||||
'state': 'cancelled',
|
||||
'error': False,
|
||||
'attachment_id': False,
|
||||
'blocking_level': False,
|
||||
})
|
||||
|
||||
if move.is_invoice(include_receipts=True) and move.state == 'posted':
|
||||
@@ -118,8 +176,11 @@ class AccountEdiDocument(models.Model):
|
||||
if not old_attachment.res_model or not old_attachment.res_id:
|
||||
attachments_to_unlink |= old_attachment
|
||||
|
||||
else:
|
||||
document.error = move_result.get('error') or _("Error when cancelling the journal entry.")
|
||||
elif not move_result.get('success'):
|
||||
document.write({
|
||||
'error': move_result.get('error', False),
|
||||
'blocking_level': move_result.get('blocking_level', DEFAULT_BLOCKING_LEVEL) if move_result.get('error') else False,
|
||||
})
|
||||
|
||||
if invoice_ids_to_cancel:
|
||||
invoices = self.env['account.move'].browse(list(invoice_ids_to_cancel))
|
||||
@@ -132,80 +193,65 @@ class AccountEdiDocument(models.Model):
|
||||
|
||||
test_mode = self._context.get('edi_test_mode', False)
|
||||
|
||||
# ==== Process invoices ====
|
||||
payments = []
|
||||
for key, batches in to_process:
|
||||
edi_format, state, doc_type, company_id = key
|
||||
if doc_type == 'payment':
|
||||
payments.append((key, batches))
|
||||
continue # payments are processed after invoices
|
||||
documents.edi_format_id.ensure_one() # All account.edi.document of a job should have the same edi_format_id
|
||||
documents.move_id.company_id.ensure_one() # All account.edi.document of a job should be from the same company
|
||||
if len(set(doc.state for doc in documents)) != 1:
|
||||
raise ValueError('All account.edi.document of a job should have the same state')
|
||||
|
||||
for documents in batches:
|
||||
move_to_cancel = documents.filtered(lambda doc: doc.edi_format_id._needs_web_services() \
|
||||
and doc.attachment_id \
|
||||
and doc.state == 'to_cancel' \
|
||||
and doc.move_id.is_invoice(include_receipts=True) \
|
||||
and doc.edi_format_id._is_required_for_invoice(doc.move_id)).move_id
|
||||
attachments_potential_unlink = documents.attachment_id.filtered(lambda a: not a.res_model and not a.res_id)
|
||||
edi_format = documents.edi_format_id
|
||||
state = documents[0].state
|
||||
if doc_type == 'invoice':
|
||||
if state == 'to_send':
|
||||
edi_result = edi_format._post_invoice_edi(documents.move_id, test_mode=test_mode)
|
||||
_postprocess_post_edi_results(documents, edi_result)
|
||||
elif state == 'to_cancel':
|
||||
edi_result = edi_format._cancel_invoice_edi(documents.move_id, test_mode=test_mode)
|
||||
_postprocess_cancel_edi_results(documents, edi_result)
|
||||
|
||||
try:
|
||||
with self.env.cr.savepoint(flush=False):
|
||||
# Locks the documents in DB. Avoid sending an invoice twice (the documents can be processed by the CRON but also manually).
|
||||
self._cr.execute('SELECT * FROM account_edi_document WHERE id IN %s FOR UPDATE NOWAIT', [tuple(documents.ids)])
|
||||
|
||||
# Locks the move that will be cancelled.
|
||||
if move_to_cancel:
|
||||
self._cr.execute('SELECT * FROM account_move WHERE id IN %s FOR UPDATE NOWAIT', [tuple(move_to_cancel.ids)])
|
||||
|
||||
# Locks the attachments that might be unlinked
|
||||
if attachments_potential_unlink:
|
||||
self._cr.execute('SELECT * FROM ir_attachment WHERE id IN %s FOR UPDATE NOWAIT', [tuple(attachments_potential_unlink.ids)])
|
||||
|
||||
if state == 'to_send':
|
||||
edi_result = edi_format._post_invoice_edi(documents.move_id, test_mode=test_mode)
|
||||
_postprocess_post_edi_results(documents, edi_result)
|
||||
elif state == 'to_cancel':
|
||||
edi_result = edi_format._cancel_invoice_edi(documents.move_id, test_mode=test_mode)
|
||||
_postprocess_cancel_edi_results(documents, edi_result)
|
||||
|
||||
except OperationalError as e:
|
||||
if e.pgcode == '55P03':
|
||||
_logger.debug('Another transaction already locked documents rows. Cannot process documents.')
|
||||
else:
|
||||
raise e
|
||||
|
||||
# ==== Process payments ====
|
||||
for key, batches in payments:
|
||||
edi_format, state, doc_type, company_id = key
|
||||
|
||||
for documents in batches:
|
||||
try:
|
||||
with self.env.cr.savepoint(flush=False):
|
||||
self._cr.execute('SELECT * FROM account_edi_document WHERE id IN %s FOR UPDATE NOWAIT', [tuple(self.ids)])
|
||||
|
||||
if state == 'to_send':
|
||||
edi_result = edi_format._post_payment_edi(documents.move_id, test_mode=test_mode)
|
||||
_postprocess_post_edi_results(documents, edi_result)
|
||||
elif state == 'to_cancel':
|
||||
edi_result = edi_format._cancel_payment_edi(documents.move_id, test_mode=test_mode)
|
||||
_postprocess_cancel_edi_results(documents, edi_result)
|
||||
|
||||
except OperationalError as e:
|
||||
if e.pgcode == '55P03':
|
||||
_logger.debug('Another transaction already locked documents rows. Cannot process documents.')
|
||||
else:
|
||||
raise e
|
||||
elif doc_type == 'payment':
|
||||
if state == 'to_send':
|
||||
edi_result = edi_format._post_payment_edi(documents.move_id, test_mode=test_mode)
|
||||
_postprocess_post_edi_results(documents, edi_result)
|
||||
elif state == 'to_cancel':
|
||||
edi_result = edi_format._cancel_payment_edi(documents.move_id, test_mode=test_mode)
|
||||
_postprocess_cancel_edi_results(documents, edi_result)
|
||||
|
||||
def _process_documents_no_web_services(self):
|
||||
""" Post and cancel all the documents that don't need a web service.
|
||||
"""
|
||||
jobs = self.filtered(lambda d: not d.edi_format_id._needs_web_services())._prepare_jobs()
|
||||
self._process_jobs(jobs)
|
||||
self._process_jobs(self._convert_to_old_jobs_format(jobs))
|
||||
|
||||
def _process_documents_web_services(self, job_count=None):
|
||||
def _process_documents_web_services(self, job_count=None, with_commit=True):
|
||||
""" Post and cancel all the documents that need a web service. This is called by CRON.
|
||||
|
||||
:param job_count: Limit to the number of jobs to process among the ones that are available for treatment.
|
||||
"""
|
||||
jobs = self.filtered(lambda d: d.edi_format_id._needs_web_services())._prepare_jobs()
|
||||
self._process_jobs(jobs[0:job_count or len(jobs)])
|
||||
jobs = jobs[0:job_count or len(jobs)]
|
||||
for documents, doc_type in jobs:
|
||||
move_to_cancel = documents.filtered(lambda doc: doc.attachment_id \
|
||||
and doc.state == 'to_cancel' \
|
||||
and doc.move_id.is_invoice(include_receipts=True) \
|
||||
and doc.edi_format_id._is_required_for_invoice(doc.move_id)).move_id
|
||||
attachments_potential_unlink = documents.attachment_id.filtered(lambda a: not a.res_model and not a.res_id)
|
||||
try:
|
||||
with self.env.cr.savepoint(flush=False):
|
||||
self._cr.execute('SELECT * FROM account_edi_document WHERE id IN %s FOR UPDATE NOWAIT', [tuple(documents.ids)])
|
||||
# Locks the move that will be cancelled.
|
||||
if move_to_cancel:
|
||||
self._cr.execute('SELECT * FROM account_move WHERE id IN %s FOR UPDATE NOWAIT', [tuple(move_to_cancel.ids)])
|
||||
|
||||
# Locks the attachments that might be unlinked
|
||||
if attachments_potential_unlink:
|
||||
self._cr.execute('SELECT * FROM ir_attachment WHERE id IN %s FOR UPDATE NOWAIT', [tuple(attachments_potential_unlink.ids)])
|
||||
|
||||
self._process_job(documents, doc_type)
|
||||
except OperationalError as e:
|
||||
if e.pgcode == '55P03':
|
||||
_logger.debug('Another transaction already locked documents rows. Cannot process documents.')
|
||||
else:
|
||||
raise e
|
||||
else:
|
||||
if with_commit and len(jobs) > 1:
|
||||
self.env.cr.commit()
|
||||
|
||||
@@ -98,7 +98,7 @@ class AccountEdiFormat(models.Model):
|
||||
# TO OVERRIDE
|
||||
return False
|
||||
|
||||
def _support_batching(self):
|
||||
def _support_batching(self, move=None, state=None, company=None):
|
||||
""" Indicate if we can send multiple documents in the same time to the web services.
|
||||
If True, the _post_%s_edi methods will get multiple documents in the same time.
|
||||
Otherwise, these methods will be called with only one record at a time.
|
||||
@@ -108,6 +108,26 @@ class AccountEdiFormat(models.Model):
|
||||
# TO OVERRIDE
|
||||
return False
|
||||
|
||||
def _get_batch_key(self, move, state):
|
||||
""" Returns a tuple that will be used as key to partitionnate the invoices/payments when creating batches
|
||||
with multiple invoices/payments.
|
||||
The type of move (invoice or payment), its company_id, its edi state and the edi_format are used by default, if
|
||||
no further partition is needed for this format, this method should return ().
|
||||
|
||||
:returns: The key to be used when partitionning the batches.
|
||||
"""
|
||||
move.ensure_one()
|
||||
return ()
|
||||
|
||||
def _check_move_configuration(self, move):
|
||||
""" Checks the move and relevant records for potential error (missing data, etc).
|
||||
|
||||
:param invoice: The move to check.
|
||||
:returns: A list of error messages.
|
||||
"""
|
||||
# TO OVERRIDE
|
||||
return []
|
||||
|
||||
def _post_invoice_edi(self, invoices, test_mode=False):
|
||||
""" Create the file content representing the invoice (and calls web services if necessary).
|
||||
|
||||
@@ -116,6 +136,7 @@ class AccountEdiFormat(models.Model):
|
||||
:returns: A dictionary with the invoice as key and as value, another dictionary:
|
||||
* attachment: The attachment representing the invoice in this edi_format if the edi was successfully posted.
|
||||
* error: An error if the edi was not successfully posted.
|
||||
* blocking_level: (optional, requires account_edi_extended) How bad is the error (how should the edi flow be blocked ?)
|
||||
"""
|
||||
# TO OVERRIDE
|
||||
self.ensure_one()
|
||||
@@ -129,6 +150,7 @@ class AccountEdiFormat(models.Model):
|
||||
:returns: A dictionary with the invoice as key and as value, another dictionary:
|
||||
* success: True if the invoice was successfully cancelled.
|
||||
* error: An error if the edi was not successfully cancelled.
|
||||
* blocking_level: (optional, requires account_edi_extended) How bad is the error (how should the edi flow be blocked ?)
|
||||
"""
|
||||
# TO OVERRIDE
|
||||
self.ensure_one()
|
||||
@@ -142,6 +164,7 @@ class AccountEdiFormat(models.Model):
|
||||
:returns: A dictionary with the payment as key and as value, another dictionary:
|
||||
* attachment: The attachment representing the payment in this edi_format if the edi was successfully posted.
|
||||
* error: An error if the edi was not successfully posted.
|
||||
* blocking_level: (optional, requires account_edi_extended) How bad is the error (how should the edi flow be blocked ?)
|
||||
"""
|
||||
# TO OVERRIDE
|
||||
self.ensure_one()
|
||||
@@ -155,6 +178,7 @@ class AccountEdiFormat(models.Model):
|
||||
:returns: A dictionary with the payment as key and as value, another dictionary:
|
||||
* success: True if the payment was successfully cancelled.
|
||||
* error: An error if the edi was not successfully cancelled.
|
||||
* blocking_level: (optional, requires account_edi_extended) How bad is the error (how should the edi flow be blocked ?)
|
||||
"""
|
||||
# TO OVERRIDE
|
||||
self.ensure_one()
|
||||
@@ -224,7 +248,7 @@ class AccountEdiFormat(models.Model):
|
||||
"""
|
||||
attachments = []
|
||||
for edi_format in self:
|
||||
attachment = invoice.edi_document_ids.filtered(lambda d: d.edi_format_id == edi_format).attachment_id
|
||||
attachment = invoice._get_edi_attachment(edi_format)
|
||||
if attachment and edi_format._is_embedding_to_invoice_pdf_needed():
|
||||
datas = base64.b64decode(attachment.with_context(bin_size=False).datas)
|
||||
attachments.append({'name': attachment.name, 'datas': datas})
|
||||
@@ -419,7 +443,7 @@ class AccountEdiFormat(models.Model):
|
||||
|
||||
:param name: The name of the product.
|
||||
:param default_code: The default_code of the product.
|
||||
:param bracode: The barcode of the product.
|
||||
:param barcode: The barcode of the product.
|
||||
:returns: A product or an empty recordset if not found.
|
||||
'''
|
||||
domains = []
|
||||
@@ -455,3 +479,12 @@ class AccountEdiFormat(models.Model):
|
||||
:returns: A currency or an empty recordset if not found.
|
||||
'''
|
||||
return self.env['res.currency'].search([('name', '=', code.upper())], limit=1)
|
||||
|
||||
####################################################
|
||||
# Other helpers
|
||||
####################################################
|
||||
|
||||
@api.model
|
||||
def _format_error_message(self, error_title, errors):
|
||||
bullet_list_msg = ''.join('<li>%s</li>' % msg for msg in errors)
|
||||
return '%s<ul>%s</ul>' % (error_title, bullet_list_msg)
|
||||
|
||||
@@ -111,6 +111,7 @@ class AccountMove(models.Model):
|
||||
existing_edi_document.write({
|
||||
'state': 'to_send',
|
||||
'error': False,
|
||||
'blocking_level': False,
|
||||
})
|
||||
else:
|
||||
edi_document_vals_list.append({
|
||||
@@ -122,6 +123,7 @@ class AccountMove(models.Model):
|
||||
existing_edi_document.write({
|
||||
'state': False,
|
||||
'error': False,
|
||||
'blocking_level': False,
|
||||
})
|
||||
|
||||
self.env['account.edi.document'].create(edi_document_vals_list)
|
||||
@@ -138,6 +140,10 @@ class AccountMove(models.Model):
|
||||
is_edi_needed = move.is_invoice(include_receipts=False) and edi_format._is_required_for_invoice(move)
|
||||
|
||||
if is_edi_needed:
|
||||
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.write({
|
||||
@@ -160,8 +166,8 @@ class AccountMove(models.Model):
|
||||
# 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.attachment_id).write({'state': 'to_cancel', 'error': False})
|
||||
self.edi_document_ids.filtered(lambda doc: not doc.attachment_id).write({'state': 'cancelled', 'error': False})
|
||||
self.edi_document_ids.filtered(lambda doc: doc.attachment_id).write({'state': 'to_cancel', 'error': False, 'blocking_level': False})
|
||||
self.edi_document_ids.filtered(lambda doc: not doc.attachment_id).write({'state': 'cancelled', 'error': False, 'blocking_level': False})
|
||||
self.edi_document_ids._process_documents_no_web_services()
|
||||
|
||||
return res
|
||||
@@ -177,7 +183,7 @@ class AccountMove(models.Model):
|
||||
|
||||
res = super().button_draft()
|
||||
|
||||
self.edi_document_ids.write({'state': False, 'error': False})
|
||||
self.edi_document_ids.write({'state': False, 'error': False, 'blocking_level': False})
|
||||
|
||||
return res
|
||||
|
||||
@@ -198,7 +204,13 @@ class AccountMove(models.Model):
|
||||
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})
|
||||
to_cancel_documents.write({'state': 'to_cancel', 'error': False, 'blocking_level': False})
|
||||
|
||||
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).attachment_id
|
||||
|
||||
####################################################
|
||||
# Import Electronic Document
|
||||
@@ -242,8 +254,12 @@ class AccountMove(models.Model):
|
||||
####################################################
|
||||
|
||||
def action_process_edi_web_services(self):
|
||||
self.edi_document_ids.filtered(lambda d: d.state in ('to_send', 'to_cancel'))._process_documents_web_services()
|
||||
|
||||
docs = self.edi_document_ids.filtered(lambda d: d.state in ('to_send', 'to_cancel'))
|
||||
if 'blocking_level' in self.env['account.edi.document']._fields:
|
||||
docs = docs.filtered(lambda d: d.blocking_level != 'error')
|
||||
else:
|
||||
docs = docs.filtered(lambda d: not d.error)
|
||||
docs._process_documents_web_services(with_commit=False)
|
||||
|
||||
class AccountMoveLine(models.Model):
|
||||
_inherit = 'account.move.line'
|
||||
|
||||
@@ -32,7 +32,7 @@ class AccountEdiTestCommon(AccountTestInvoicingCommon):
|
||||
####################################################
|
||||
|
||||
def edi_cron(self):
|
||||
self.env['account.edi.document'].sudo().with_context(edi_test_mode=True).search([('state', 'in', ('to_send', 'to_cancel'))])._process_documents_web_services()
|
||||
self.env['account.edi.document'].sudo().with_context(edi_test_mode=True).search([('state', 'in', ('to_send', 'to_cancel'))])._process_documents_web_services(with_commit=False)
|
||||
|
||||
def _create_empty_vendor_bill(self):
|
||||
invoice = self.env['account.move'].create({
|
||||
@@ -71,8 +71,10 @@ class AccountEdiTestCommon(AccountTestInvoicingCommon):
|
||||
|
||||
def assert_generated_file_equal(self, invoice, expected_values, applied_xpath=None):
|
||||
invoice.action_post()
|
||||
invoice.edi_document_ids._process_documents_web_services() # synchronous are called in post, but there's no CRON in tests for asynchronous
|
||||
attachment = invoice.edi_document_ids.filtered(lambda d: d.edi_format_id == self.edi_format).attachment_id
|
||||
invoice.edi_document_ids._process_documents_web_services(with_commit=False) # synchronous are called in post, but there's no CRON in tests for asynchronous
|
||||
attachment = invoice._get_edi_attachment(self.edi_format)
|
||||
if not attachment:
|
||||
raise ValueError('No attachment was generated after posting EDI')
|
||||
xml_content = base64.b64decode(attachment.with_context(bin_size=False).datas)
|
||||
current_etree = self.get_xml_tree_from_string(xml_content)
|
||||
expected_etree = self.get_xml_tree_from_string(expected_values)
|
||||
@@ -80,20 +82,34 @@ class AccountEdiTestCommon(AccountTestInvoicingCommon):
|
||||
expected_etree = self.with_applied_xpath(expected_etree, applied_xpath)
|
||||
self.assertXmlTreeEqual(current_etree, expected_etree)
|
||||
|
||||
def create_edi_document(self, edi_format, state, move=None, move_type=None):
|
||||
""" Creates a document based on an existing invoice or creates one, too.
|
||||
|
||||
:param edi_format: The edi_format of the document.
|
||||
:param state: The state of the document.
|
||||
:param move: The move of the document or None to create a new one.
|
||||
:param move_type: If move is None, the type of the invoice to create, defaults to 'out_invoice'.
|
||||
"""
|
||||
move = move or self.init_invoice(move_type or 'out_invoice')
|
||||
return self.env['account.edi.document'].create({
|
||||
'edi_format_id': edi_format.id,
|
||||
'move_id': move.id,
|
||||
'state': state
|
||||
})
|
||||
|
||||
def _process_documents_web_services(self, moves, formats_to_return=None):
|
||||
""" Generates and returns EDI files for the specified moves.
|
||||
formats_to_return is an optional parameter used to pass a set of codes from
|
||||
the formats we want to return the files for (in case we want to test specific formats).
|
||||
Other formats will still generate documents, they simply won't be returned.
|
||||
"""
|
||||
moves.edi_document_ids.with_context(edi_test_mode=True)._process_documents_web_services()
|
||||
moves.edi_document_ids.with_context(edi_test_mode=True)._process_documents_web_services(with_commit=False)
|
||||
|
||||
documents_to_return = moves.edi_document_ids
|
||||
if formats_to_return != None:
|
||||
documents_to_return = documents_to_return.filtered(lambda x: x.edi_format_id.code in formats_to_return)
|
||||
|
||||
attachments = documents_to_return.attachment_id
|
||||
|
||||
data_str_list = []
|
||||
for attachment in attachments.with_context(bin_size=False):
|
||||
data_str_list.append(base64.decodebytes(attachment.datas))
|
||||
|
||||
@@ -14,17 +14,10 @@ class TestAccountEdi(AccountEdiTestCommon):
|
||||
self.assertEqual(len(invoice.edi_document_ids), 1)
|
||||
|
||||
def test_prepare_jobs(self):
|
||||
def create_edi_document(edi_format, state, move=None, move_type=None):
|
||||
move = move or self.init_invoice(move_type or 'out_invoice')
|
||||
return self.env['account.edi.document'].create({
|
||||
'edi_format_id': edi_format.id,
|
||||
'move_id': move.id,
|
||||
'state': state
|
||||
})
|
||||
|
||||
edi_docs = self.env['account.edi.document']
|
||||
edi_docs |= create_edi_document(self.edi_format, 'to_send')
|
||||
edi_docs |= create_edi_document(self.edi_format, 'to_send')
|
||||
edi_docs |= self.create_edi_document(self.edi_format, 'to_send')
|
||||
edi_docs |= self.create_edi_document(self.edi_format, 'to_send')
|
||||
|
||||
to_process = edi_docs._prepare_jobs()
|
||||
self.assertEqual(len(to_process), 2)
|
||||
@@ -38,8 +31,8 @@ class TestAccountEdi(AccountEdiTestCommon):
|
||||
'code': 'test_batch_edi_2',
|
||||
})
|
||||
|
||||
edi_docs |= create_edi_document(other_edi, 'to_send')
|
||||
edi_docs |= create_edi_document(other_edi, 'to_send')
|
||||
edi_docs |= self.create_edi_document(other_edi, 'to_send')
|
||||
edi_docs |= self.create_edi_document(other_edi, 'to_send')
|
||||
|
||||
with patch('odoo.addons.account_edi.models.account_edi_format.AccountEdiFormat._support_batching', return_value=True):
|
||||
to_process = edi_docs._prepare_jobs()
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</xpath>
|
||||
<xpath expr="//header" position="after">
|
||||
<div class="alert alert-info" role="alert" style="margin-bottom:0px;"
|
||||
attrs="{'invisible': [('edi_web_services_to_process', 'in', ['', False])]}">
|
||||
attrs="{'invisible': ['|', ('edi_web_services_to_process', 'in', ['', False]), ('state', '=', 'draft')]}">
|
||||
<div>The invoice will be sent asynchronously to :
|
||||
<field name="edi_web_services_to_process" class="oe_inline"/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
from . import models
|
||||
from odoo import api, SUPERUSER_ID
|
||||
|
||||
|
||||
def account_edi_block_level(cr, registery):
|
||||
''' The default value for blocking_level is 'error', but without this module,
|
||||
the behavior is the same as a blocking_level of 'warning' so we need to set
|
||||
all documents in error.
|
||||
'''
|
||||
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
env['account.edi.document'].search([('error', '!=', False)]).write({'blocking_level': 'warning'})
|
||||
@@ -0,0 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name' : 'Additionnal features for account_edi',
|
||||
'description':"""
|
||||
This module add features to account_edi to support new Edi formats.
|
||||
""",
|
||||
'version' : '1.0',
|
||||
'category': 'Accounting/Accounting',
|
||||
'depends' : ['account_edi'],
|
||||
'data': [
|
||||
'views/account_edi_document_views.xml',
|
||||
'views/account_move_views.xml',
|
||||
'views/account_payment_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
'post_init_hook': 'account_edi_block_level',
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * account_edi_extended
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 14.0+e\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2021-01-05 11:07+0000\n"
|
||||
"PO-Revision-Date: 2021-01-05 11:07+0000\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#. module: account_edi_extended
|
||||
#: code:addons/account_edi_extended/models/account_move.py:0
|
||||
#, python-format
|
||||
msgid " Electronic invoicing error(s)"
|
||||
msgstr ""
|
||||
|
||||
#. module: account_edi_extended
|
||||
#: code:addons/account_edi_extended/models/account_move.py:0
|
||||
#, python-format
|
||||
msgid " Electronic invoicing info(s)"
|
||||
msgstr ""
|
||||
|
||||
#. module: account_edi_extended
|
||||
#: code:addons/account_edi_extended/models/account_move.py:0
|
||||
#, python-format
|
||||
msgid " Electronic invoicing warning(s)"
|
||||
msgstr ""
|
||||
|
||||
#. module: account_edi_extended
|
||||
#: code:addons/account_edi_extended/models/account_move.py:0
|
||||
#, python-format
|
||||
msgid "A request for cancellation of the EDI has been called off."
|
||||
msgstr ""
|
||||
|
||||
#. module: account_edi_extended
|
||||
#: model:ir.model.fields,field_description:account_edi_extended.field_account_edi_document__blocking_level
|
||||
msgid "Blocking Level"
|
||||
msgstr ""
|
||||
|
||||
#. module: account_edi_extended
|
||||
#: model:ir.model.fields,help:account_edi_extended.field_account_edi_document__blocking_level
|
||||
msgid ""
|
||||
"Blocks the document current operation depending on the error severity :\n"
|
||||
" * Info: the document is not blocked and everything is working as it should.\n"
|
||||
" * Warning : there is an error that doesn't prevent the current Electronic Invoicing operation to succeed.\n"
|
||||
" * Error : there is an error that blocks the current Electronic Invoicing operation."
|
||||
msgstr ""
|
||||
|
||||
#. module: account_edi_extended
|
||||
#: model_terms:ir.ui.view,arch_db:account_edi_extended.view_move_form_inherit
|
||||
msgid "Call off EDI Cancellation"
|
||||
msgstr ""
|
||||
|
||||
#. module: account_edi_extended
|
||||
#: model:ir.model.fields,field_description:account_edi_extended.field_account_edi_document__display_name
|
||||
#: model:ir.model.fields,field_description:account_edi_extended.field_account_move__display_name
|
||||
#: model:ir.model.fields,field_description:account_edi_extended.field_account_payment__display_name
|
||||
msgid "Display Name"
|
||||
msgstr ""
|
||||
|
||||
#. module: account_edi_extended
|
||||
#: model:ir.model.fields,field_description:account_edi_extended.field_account_bank_statement_line__edi_blocking_level
|
||||
#: model:ir.model.fields,field_description:account_edi_extended.field_account_move__edi_blocking_level
|
||||
#: model:ir.model.fields,field_description:account_edi_extended.field_account_payment__edi_blocking_level
|
||||
msgid "Edi Blocking Level"
|
||||
msgstr ""
|
||||
|
||||
#. module: account_edi_extended
|
||||
#: model:ir.model.fields,field_description:account_edi_extended.field_account_bank_statement_line__edi_error_message
|
||||
#: model:ir.model.fields,field_description:account_edi_extended.field_account_move__edi_error_message
|
||||
#: model:ir.model.fields,field_description:account_edi_extended.field_account_payment__edi_error_message
|
||||
msgid "Edi Error Message"
|
||||
msgstr ""
|
||||
|
||||
#. module: account_edi_extended
|
||||
#: model:ir.model.fields,field_description:account_edi_extended.field_account_bank_statement_line__edi_show_abandon_cancel_button
|
||||
#: model:ir.model.fields,field_description:account_edi_extended.field_account_move__edi_show_abandon_cancel_button
|
||||
#: model:ir.model.fields,field_description:account_edi_extended.field_account_payment__edi_show_abandon_cancel_button
|
||||
msgid "Edi Show Abandon Cancel Button"
|
||||
msgstr ""
|
||||
|
||||
#. module: account_edi_extended
|
||||
#: model:ir.model,name:account_edi_extended.model_account_edi_document
|
||||
msgid "Electronic Document for an account.move"
|
||||
msgstr ""
|
||||
|
||||
#. module: account_edi_extended
|
||||
#: model:ir.model.fields.selection,name:account_edi_extended.selection__account_edi_document__blocking_level__error
|
||||
#: model:ir.model.fields.selection,name:account_edi_extended.selection__account_move__edi_blocking_level__error
|
||||
msgid "Error"
|
||||
msgstr ""
|
||||
|
||||
#. module: account_edi_extended
|
||||
#: model:ir.model.fields,field_description:account_edi_extended.field_account_edi_document__id
|
||||
#: model:ir.model.fields,field_description:account_edi_extended.field_account_move__id
|
||||
#: model:ir.model.fields,field_description:account_edi_extended.field_account_payment__id
|
||||
msgid "ID"
|
||||
msgstr ""
|
||||
|
||||
#. module: account_edi_extended
|
||||
#: model:ir.model.fields.selection,name:account_edi_extended.selection__account_edi_document__blocking_level__info
|
||||
#: model:ir.model.fields.selection,name:account_edi_extended.selection__account_move__edi_blocking_level__info
|
||||
msgid "Info"
|
||||
msgstr ""
|
||||
|
||||
#. module: account_edi_extended
|
||||
#: code:addons/account_edi_extended/models/account_edi_document.py:0
|
||||
#, python-format
|
||||
msgid "Invalid configuration:"
|
||||
msgstr ""
|
||||
|
||||
#. module: account_edi_extended
|
||||
#: model:ir.model,name:account_edi_extended.model_account_move
|
||||
msgid "Journal Entry"
|
||||
msgstr ""
|
||||
|
||||
#. module: account_edi_extended
|
||||
#: model:ir.model.fields,field_description:account_edi_extended.field_account_edi_document____last_update
|
||||
#: model:ir.model.fields,field_description:account_edi_extended.field_account_move____last_update
|
||||
#: model:ir.model.fields,field_description:account_edi_extended.field_account_payment____last_update
|
||||
msgid "Last Modified on"
|
||||
msgstr ""
|
||||
|
||||
#. module: account_edi_extended
|
||||
#: model:ir.model,name:account_edi_extended.model_account_payment
|
||||
msgid "Payments"
|
||||
msgstr ""
|
||||
|
||||
#. module: account_edi_extended
|
||||
#: model_terms:ir.ui.view,arch_db:account_edi_extended.view_move_form_inherit
|
||||
#: model_terms:ir.ui.view,arch_db:account_edi_extended.view_payment_form_inherit
|
||||
msgid "Retry"
|
||||
msgstr ""
|
||||
|
||||
#. module: account_edi_extended
|
||||
#: model:ir.model.fields.selection,name:account_edi_extended.selection__account_edi_document__blocking_level__warning
|
||||
#: model:ir.model.fields.selection,name:account_edi_extended.selection__account_move__edi_blocking_level__warning
|
||||
msgid "Warning"
|
||||
msgstr ""
|
||||
|
||||
#. module: account_edi_extended
|
||||
#: model_terms:ir.ui.view,arch_db:account_edi_extended.view_move_form_inherit
|
||||
msgid "⇒ See errors"
|
||||
msgstr ""
|
||||
@@ -0,0 +1,3 @@
|
||||
from . import account_edi_document
|
||||
from . import account_move
|
||||
from . import account_payment
|
||||
@@ -0,0 +1,17 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from odoo import models, fields
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
DEFAULT_BLOCKING_LEVEL = 'warning' # Keep previous behavior. TODO : when account_edi_extended is merged with account_edi, should be 'error' (document will not be processed again until forced retry or reset to draft)
|
||||
|
||||
|
||||
class AccountEdiDocument(models.Model):
|
||||
_inherit = 'account.edi.document'
|
||||
|
||||
blocking_level = fields.Selection(selection=[('info', 'Info'), ('warning', 'Warning'), ('error', 'Error')],
|
||||
help="Blocks the document current operation depending on the error severity :\n"
|
||||
" * Info: the document is not blocked and everything is working as it should.\n"
|
||||
" * Warning : there is an error that doesn't prevent the current Electronic Invoicing operation to succeed.\n"
|
||||
" * Error : there is an error that blocks the current Electronic Invoicing operation.")
|
||||
@@ -0,0 +1,81 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, models, fields, _
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = 'account.move'
|
||||
|
||||
edi_show_abandon_cancel_button = fields.Boolean(
|
||||
compute='_compute_edi_show_abandon_cancel_button')
|
||||
edi_error_message = fields.Html(compute='_compute_edi_error_message')
|
||||
edi_blocking_level = fields.Selection(selection=[('info', 'Info'), ('warning', 'Warning'), ('error', 'Error')], compute='_compute_edi_error_message')
|
||||
|
||||
@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):
|
||||
# OVERRIDE to take blocking_level into account
|
||||
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(
|
||||
'state',
|
||||
'edi_document_ids.state',
|
||||
'edi_document_ids.attachment_id')
|
||||
def _compute_edi_show_abandon_cancel_button(self):
|
||||
for move in self:
|
||||
move.edi_show_abandon_cancel_button = any(doc.edi_format_id._needs_web_services()
|
||||
and doc.state == 'to_cancel'
|
||||
and move.is_invoice(include_receipts=True)
|
||||
and doc.edi_format_id._is_required_for_invoice(move)
|
||||
for doc in move.edi_document_ids)
|
||||
|
||||
@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'
|
||||
|
||||
def action_retry_edi_documents_error(self):
|
||||
self.edi_document_ids.write({'error': False, 'blocking_level': False})
|
||||
self.action_process_edi_web_services()
|
||||
|
||||
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:
|
||||
if doc.state == 'to_cancel' \
|
||||
and move.is_invoice(include_receipts=True) \
|
||||
and doc.edi_format_id._is_required_for_invoice(move):
|
||||
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'})
|
||||
@@ -0,0 +1,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class AccountPayment(models.Model):
|
||||
_inherit = 'account.payment'
|
||||
|
||||
def action_retry_edi_documents_error(self):
|
||||
self.ensure_one()
|
||||
return self.move_id.action_retry_edi_documents_error()
|
||||
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import common
|
||||
from . import test_edi
|
||||
@@ -0,0 +1,114 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.addons.account_edi.tests.common import AccountEdiTestCommon
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import patch
|
||||
import base64
|
||||
|
||||
# TODO for the test to work, we need a chart template (COA) but we don't have any and don't want to add dependency (create empty coa ?)
|
||||
|
||||
|
||||
def _generate_mocked_needs_web_services(needs_web_services):
|
||||
return lambda edi_format: needs_web_services
|
||||
|
||||
|
||||
def _generate_mocked_support_batching(support_batching):
|
||||
return lambda edi_format, move, state, company: support_batching
|
||||
|
||||
|
||||
def _mocked_get_batch_key(edi_format, move, state):
|
||||
return ()
|
||||
|
||||
|
||||
def _mocked_check_move_configuration_success(edi_format, move):
|
||||
return []
|
||||
|
||||
|
||||
def _mocked_check_move_configuration_fail(edi_format, move):
|
||||
return ['Fake error (mocked)']
|
||||
|
||||
|
||||
def _mocked_post(edi_format, invoices, test_mode):
|
||||
res = {}
|
||||
for invoice in invoices:
|
||||
attachment = edi_format.env['ir.attachment'].create({
|
||||
'name': 'mock_simple.xml',
|
||||
'datas': base64.encodebytes(b"<?xml version='1.0' encoding='UTF-8'?><Invoice/>"),
|
||||
'mimetype': 'application/xml'
|
||||
})
|
||||
res[invoice] = {'attachment': attachment}
|
||||
return res
|
||||
|
||||
|
||||
def _mocked_post_two_steps(edi_format, invoices, test_mode):
|
||||
# For this test, we use the field ref to know if the first step is already done or not.
|
||||
# Typically, a technical field for the reference of the upload to the web-service will
|
||||
# be saved on the invoice.
|
||||
invoices_no_ref = invoices.filtered(lambda i: not i.ref)
|
||||
if len(invoices_no_ref) == len(invoices): # first step
|
||||
invoices_no_ref.ref = 'test_ref'
|
||||
return {invoice: {} for invoice in invoices}
|
||||
elif len(invoices_no_ref) == 0: # second step
|
||||
res = {}
|
||||
for invoice in invoices:
|
||||
attachment = edi_format.env['ir.attachment'].create({
|
||||
'name': 'mock_simple.xml',
|
||||
'datas': base64.encodebytes(b"<?xml version='1.0' encoding='UTF-8'?><Invoice/>"),
|
||||
'mimetype': 'application/xml'
|
||||
})
|
||||
res[invoice] = {'attachment': attachment}
|
||||
return res
|
||||
else:
|
||||
raise ValueError('wrong use of "_mocked_post_two_steps"')
|
||||
|
||||
|
||||
def _mocked_cancel_success(edi_format, invoices, test_mode):
|
||||
return {invoice: {'success': True} for invoice in invoices}
|
||||
|
||||
|
||||
def _mocked_cancel_failed(edi_format, invoices, test_mode):
|
||||
return {invoice: {'error': 'Faked error (mocked)'} for invoice in invoices}
|
||||
|
||||
|
||||
class AccountEdiExtendedTestCommon(AccountEdiTestCommon):
|
||||
|
||||
@contextmanager
|
||||
def mock_edi(self,
|
||||
_is_required_for_invoice_method=lambda edi_format, invoice: True,
|
||||
_is_required_for_payment_method=lambda edi_format, invoice: True,
|
||||
_support_batching_method=_generate_mocked_support_batching(False),
|
||||
_get_batch_key_method=_mocked_get_batch_key,
|
||||
_needs_web_services_method=_generate_mocked_needs_web_services(False),
|
||||
_check_move_configuration_method=_mocked_check_move_configuration_success,
|
||||
_post_invoice_edi_method=_mocked_post,
|
||||
_cancel_invoice_edi_method=_mocked_cancel_success,
|
||||
_post_payment_edi_method=_mocked_post,
|
||||
_cancel_payment_edi_method=_mocked_cancel_success,
|
||||
):
|
||||
|
||||
try:
|
||||
with patch('odoo.addons.account_edi.models.account_edi_format.AccountEdiFormat._is_required_for_invoice',
|
||||
new=_is_required_for_invoice_method), \
|
||||
patch('odoo.addons.account_edi.models.account_edi_format.AccountEdiFormat._is_required_for_payment',
|
||||
new=_is_required_for_payment_method), \
|
||||
patch('odoo.addons.account_edi.models.account_edi_format.AccountEdiFormat._needs_web_services',
|
||||
new=_needs_web_services_method), \
|
||||
patch('odoo.addons.account_edi.models.account_edi_format.AccountEdiFormat._support_batching',
|
||||
new=_support_batching_method), \
|
||||
patch('odoo.addons.account_edi.models.account_edi_format.AccountEdiFormat._get_batch_key',
|
||||
new=_get_batch_key_method), \
|
||||
patch('odoo.addons.account_edi.models.account_edi_format.AccountEdiFormat._check_move_configuration',
|
||||
new=_check_move_configuration_method), \
|
||||
patch('odoo.addons.account_edi.models.account_edi_format.AccountEdiFormat._post_invoice_edi',
|
||||
new=_post_invoice_edi_method), \
|
||||
patch('odoo.addons.account_edi.models.account_edi_format.AccountEdiFormat._cancel_invoice_edi',
|
||||
new=_cancel_invoice_edi_method), \
|
||||
patch('odoo.addons.account_edi.models.account_edi_format.AccountEdiFormat._post_payment_edi',
|
||||
new=_post_payment_edi_method), \
|
||||
patch('odoo.addons.account_edi.models.account_edi_format.AccountEdiFormat._cancel_payment_edi',
|
||||
new=_cancel_payment_edi_method):
|
||||
|
||||
yield
|
||||
finally:
|
||||
pass
|
||||
@@ -0,0 +1,157 @@
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.addons.account_edi_extended.tests.common import AccountEdiExtendedTestCommon, _mocked_post_two_steps, _generate_mocked_needs_web_services, _mocked_cancel_failed, _generate_mocked_support_batching
|
||||
|
||||
|
||||
class TestAccountEdi(AccountEdiExtendedTestCommon):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls, chart_template_ref=None, edi_format_ref=None):
|
||||
super().setUpClass(chart_template_ref=chart_template_ref, edi_format_ref=edi_format_ref)
|
||||
|
||||
cls.invoice = cls.init_invoice('out_invoice', products=cls.product_a + cls.product_b)
|
||||
|
||||
def test_edi_flow(self):
|
||||
with self.mock_edi():
|
||||
doc = self.invoice._get_edi_document(self.edi_format)
|
||||
self.assertFalse(doc)
|
||||
self.invoice.action_post()
|
||||
doc = self.invoice._get_edi_document(self.edi_format)
|
||||
self.assertEqual(len(doc), 1)
|
||||
self.assertEqual(doc.state, 'sent')
|
||||
self.invoice.button_draft()
|
||||
self.invoice.button_cancel()
|
||||
self.assertEqual(doc.state, 'cancelled')
|
||||
|
||||
def test_edi_flow_two_steps(self):
|
||||
with self.mock_edi(_post_invoice_edi_method=_mocked_post_two_steps,
|
||||
_needs_web_services_method=_generate_mocked_needs_web_services(True)):
|
||||
doc = self.invoice._get_edi_document(self.edi_format)
|
||||
self.assertFalse(doc)
|
||||
self.invoice.action_post()
|
||||
doc = self.invoice._get_edi_document(self.edi_format)
|
||||
self.assertEqual(len(doc), 1)
|
||||
self.assertEqual(doc.state, 'to_send')
|
||||
doc._process_documents_web_services(with_commit=False)
|
||||
self.assertEqual(doc.state, 'to_send')
|
||||
doc._process_documents_web_services(with_commit=False)
|
||||
self.assertEqual(doc.state, 'sent')
|
||||
|
||||
def test_edi_flow_request_cancel_success(self):
|
||||
with self.mock_edi(_needs_web_services_method=_generate_mocked_needs_web_services(True)):
|
||||
self.assertEqual(self.invoice.state, 'draft')
|
||||
self.invoice.action_post()
|
||||
doc = self.invoice._get_edi_document(self.edi_format)
|
||||
self.assertEqual(doc.state, 'to_send')
|
||||
self.assertEqual(self.invoice.state, 'posted')
|
||||
doc._process_documents_web_services(with_commit=False)
|
||||
self.assertEqual(doc.state, 'sent')
|
||||
self.assertEqual(self.invoice.state, 'posted')
|
||||
self.invoice.button_cancel_posted_moves()
|
||||
self.assertEqual(doc.state, 'to_cancel')
|
||||
self.assertEqual(self.invoice.state, 'posted')
|
||||
doc._process_documents_web_services()
|
||||
self.assertEqual(doc.state, 'cancelled')
|
||||
self.assertEqual(self.invoice.state, 'cancel')
|
||||
|
||||
def test_edi_flow_request_cancel_failed(self):
|
||||
with self.mock_edi(_needs_web_services_method=_generate_mocked_needs_web_services(True),
|
||||
_cancel_invoice_edi_method=_mocked_cancel_failed):
|
||||
self.assertEqual(self.invoice.state, 'draft')
|
||||
self.invoice.action_post()
|
||||
doc = self.invoice._get_edi_document(self.edi_format)
|
||||
self.assertEqual(doc.state, 'to_send')
|
||||
self.assertEqual(self.invoice.state, 'posted')
|
||||
doc._process_documents_web_services(with_commit=False)
|
||||
self.assertEqual(doc.state, 'sent')
|
||||
self.assertEqual(self.invoice.state, 'posted')
|
||||
self.invoice.button_cancel_posted_moves()
|
||||
self.assertEqual(doc.state, 'to_cancel')
|
||||
self.assertEqual(self.invoice.state, 'posted')
|
||||
# Call off edi Cancellation
|
||||
self.invoice.button_abandon_cancel_posted_posted_moves()
|
||||
self.assertEqual(doc.state, 'sent')
|
||||
self.assertFalse(doc.error)
|
||||
|
||||
# Failed cancel
|
||||
self.invoice.button_cancel_posted_moves()
|
||||
self.assertEqual(doc.state, 'to_cancel')
|
||||
self.assertEqual(self.invoice.state, 'posted')
|
||||
doc._process_documents_web_services()
|
||||
self.assertEqual(doc.state, 'to_cancel')
|
||||
self.assertEqual(self.invoice.state, 'posted')
|
||||
|
||||
# Call off edi Cancellation
|
||||
self.invoice.button_abandon_cancel_posted_posted_moves()
|
||||
self.assertEqual(doc.state, 'sent')
|
||||
self.assertIsNotNone(doc.error)
|
||||
|
||||
def test_edi_flow_two_step_cancel_with_call_off_request(self):
|
||||
def _mock_cancel(edi_format, invoices, test_mode):
|
||||
invoices_no_ref = invoices.filtered(lambda i: not i.ref)
|
||||
if len(invoices_no_ref) == len(invoices): # first step
|
||||
invoices_no_ref.ref = 'test_ref_cancel'
|
||||
return {invoice: {} for invoice in invoices}
|
||||
elif len(invoices_no_ref) == 0: # second step
|
||||
for invoice in invoices:
|
||||
invoice.ref = None
|
||||
return {invoice: {'success': True} for invoice in invoices}
|
||||
else:
|
||||
raise ValueError('wrong use of "_mocked_post_two_steps"')
|
||||
|
||||
def _is_needed_for_invoice(edi_format, invoice):
|
||||
return not bool(invoice.ref)
|
||||
|
||||
with self.mock_edi(_needs_web_services_method=_generate_mocked_needs_web_services(True),
|
||||
_is_required_for_invoice_method=_is_needed_for_invoice,
|
||||
_cancel_invoice_edi_method=_mock_cancel):
|
||||
self.invoice.action_post()
|
||||
doc = self.invoice._get_edi_document(self.edi_format)
|
||||
doc._process_documents_web_services(with_commit=False)
|
||||
self.assertEqual(doc.state, 'sent')
|
||||
|
||||
# Request Cancellation
|
||||
self.invoice.button_cancel_posted_moves()
|
||||
doc._process_documents_web_services(with_commit=False) # first step of cancel
|
||||
self.assertEqual(doc.state, 'to_cancel')
|
||||
|
||||
# Call off edi Cancellation
|
||||
self.invoice.button_abandon_cancel_posted_posted_moves()
|
||||
self.assertEqual(doc.state, 'to_cancel')
|
||||
|
||||
# If we cannot call off edi cancellation, only solution is to post again
|
||||
doc._process_documents_web_services(with_commit=False) # second step of cancel
|
||||
self.assertEqual(doc.state, 'cancelled')
|
||||
self.invoice.action_post()
|
||||
doc._process_documents_web_services(with_commit=False)
|
||||
self.assertEqual(doc.state, 'sent')
|
||||
|
||||
def test_batches(self):
|
||||
def _get_batch_key_method(edi_format, move, state):
|
||||
return (move.ref)
|
||||
|
||||
with self.mock_edi(_get_batch_key_method=_get_batch_key_method,
|
||||
_support_batching_method=_generate_mocked_support_batching(True)):
|
||||
edi_docs = self.env['account.edi.document']
|
||||
doc1 = self.create_edi_document(self.edi_format, 'to_send')
|
||||
edi_docs |= doc1
|
||||
doc2 = self.create_edi_document(self.edi_format, 'to_send')
|
||||
edi_docs |= doc2
|
||||
doc3 = self.create_edi_document(self.edi_format, 'to_send')
|
||||
edi_docs |= doc3
|
||||
|
||||
to_process = edi_docs._prepare_jobs()
|
||||
self.assertEqual(len(to_process), 1)
|
||||
|
||||
doc1.move_id.ref = 'batch1'
|
||||
doc2.move_id.ref = 'batch2'
|
||||
doc3.move_id.ref = 'batch3'
|
||||
|
||||
to_process = edi_docs._prepare_jobs()
|
||||
self.assertEqual(len(to_process), 3)
|
||||
|
||||
doc2.move_id.ref = 'batch1'
|
||||
to_process = edi_docs._prepare_jobs()
|
||||
self.assertEqual(len(to_process), 2)
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<record id="view_tree_account_edi_document_inherit" model="ir.ui.view">
|
||||
<field name="name">Account.edi.document.tree.inherit</field>
|
||||
<field name="model">account.edi.document</field>
|
||||
<field name="inherit_id" ref="account_edi.view_tree_account_edi_document"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//tree" position="inside">
|
||||
<field name="blocking_level" invisible="1" />
|
||||
</xpath>
|
||||
<xpath expr="//tree" position="attributes">
|
||||
<attribute name="decoration-info">blocking_level == 'info'</attribute>
|
||||
<attribute name="decoration-warning">blocking_level == 'warning'</attribute>
|
||||
<attribute name="decoration-danger">blocking_level == 'error'</attribute>
|
||||
</xpath>
|
||||
|
||||
</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
|
||||
<record id="view_move_form_inherit" model="ir.ui.view">
|
||||
<field name="name">account.move.form.inherit</field>
|
||||
<field name="model">account.move</field>
|
||||
<field name="inherit_id" ref="account_edi.view_move_form_inherit" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//button[@name='%(account_edi.action_open_edi_documents)d']" position="after">
|
||||
<button name="action_retry_edi_documents_error" type="object" class="oe_link oe_inline" string="Retry" />
|
||||
</xpath>
|
||||
<xpath expr="//button[@name='button_cancel_posted_moves']" position="after">
|
||||
<field name="edi_show_abandon_cancel_button" invisible="1"/>
|
||||
<button name="button_abandon_cancel_posted_posted_moves"
|
||||
string="Call off EDI Cancellation"
|
||||
type="object"
|
||||
groups="account.group_account_invoice"
|
||||
attrs="{'invisible' : [('edi_show_abandon_cancel_button', '=', False)]}"/>
|
||||
</xpath>
|
||||
<!-- Nasty xpath to replace the error count warning banner. In master, it will be merged. -->
|
||||
<xpath expr="//div[hasclass('alert-warning')]" position="replace">
|
||||
<field name="edi_blocking_level" invisible="1" />
|
||||
<field name="edi_error_count" invisible="1" />
|
||||
<div class="alert alert-danger" role="alert" style="margin-bottom:0px;"
|
||||
attrs="{'invisible': ['|', ('edi_error_count', '=', 0), ('edi_blocking_level', '!=', 'error')]}">
|
||||
<div class="o_row">
|
||||
<field name="edi_error_message" />
|
||||
<button name="%(account_edi.action_open_edi_documents)d" string="⇒ See errors" type="action" class="oe_link" attrs="{'invisible': [('edi_error_count', '=', 1)]}" />
|
||||
<button name="action_retry_edi_documents_error" type="object" class="oe_link oe_inline" string="Retry" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-warning" role="alert" style="margin-bottom:0px;"
|
||||
attrs="{'invisible': ['|', ('edi_error_count', '=', 0), ('edi_blocking_level', '!=', 'warning')]}">
|
||||
<div class="o_row">
|
||||
<field name="edi_error_message" />
|
||||
<button name="%(account_edi.action_open_edi_documents)d" string="⇒ See errors" type="action" class="oe_link" attrs="{'invisible': [('edi_error_count', '=', 1)]}" />
|
||||
<button name="action_retry_edi_documents_error" type="object" class="oe_link oe_inline" string="Retry" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-info" role="alert" style="margin-bottom:0px;"
|
||||
attrs="{'invisible': ['|', ('edi_error_count', '=', 0), ('edi_blocking_level', '!=', 'info')]}">
|
||||
<div class="o_row">
|
||||
<field name="edi_error_message" />
|
||||
<button name="%(account_edi.action_open_edi_documents)d" string="⇒ See errors" type="action" class="oe_link" attrs="{'invisible': [('edi_error_count', '=', 1)]}" />
|
||||
<button name="action_retry_edi_documents_error" type="object" class="oe_link oe_inline" string="Retry" />
|
||||
</div>
|
||||
</div>
|
||||
</xpath>
|
||||
|
||||
</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<record id="view_payment_form_inherit" model="ir.ui.view">
|
||||
<field name="name">account.payment.form.inherit</field>
|
||||
<field name="model">account.payment</field>
|
||||
<field name="inherit_id" ref="account_edi.view_payment_form_inherit" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//button[@name='%(account_edi.action_open_payment_edi_documents)d']" position="after">
|
||||
<button name="action_retry_edi_documents_error" type="object" class="oe_link oe_inline" string="Retry" />
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user