diff options
| author | Benjamin Frantzen (bfr) <bfr@odoo.com> | 2020-11-24 09:41:01 +0000 |
|---|---|---|
| committer | Benjamin Frantzen (bfr) <bfr@odoo.com> | 2021-01-28 08:57:25 +0000 |
| commit | a9a46cf09b0c841c7fda95b5ed03033fc6936ca2 (patch) | |
| tree | b3d8715d149568fe50e4557877edf06c34ef2c27 /addons | |
| parent | 06a66497b658d22d43187f9bd430cb747ee6c4a3 (diff) | |
[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.
Diffstat (limited to 'addons')
20 files changed, 962 insertions, 117 deletions
diff --git a/addons/account_edi/i18n/account_edi.pot b/addons/account_edi/i18n/account_edi.pot index f981d2bfb63..82310a15193 100644 --- a/addons/account_edi/i18n/account_edi.pot +++ b/addons/account_edi/i18n/account_edi.pot @@ -309,6 +309,11 @@ 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 #: model:ir.model.fields,help:account_edi.field_account_payment__edi_web_services_to_process diff --git a/addons/account_edi/models/account_edi_document.py b/addons/account_edi/models/account_edi_document.py index c5dc475e238..fd5396e955a 100644 --- a/addons/account_edi/models/account_edi_document.py +++ b/addons/account_edi/models/account_edi_document.py @@ -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: + edi_format, state, doc_type, company_id = 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 - - 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) - - try: - with self.env.cr.savepoint(): - # 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(): - 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 + 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') + + 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) + + 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(): + 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() diff --git a/addons/account_edi/models/account_edi_format.py b/addons/account_edi/models/account_edi_format.py index 4313e78ddfc..36c36941625 100644 --- a/addons/account_edi/models/account_edi_format.py +++ b/addons/account_edi/models/account_edi_format.py @@ -4,6 +4,7 @@ from odoo import models, fields, api from odoo.exceptions import UserError from odoo.tools.pdf import OdooPdfFileReader, OdooPdfFileWriter +from odoo.osv import expression from lxml import etree import base64 @@ -97,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. @@ -107,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). @@ -115,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() @@ -128,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() @@ -141,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() @@ -154,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() @@ -223,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}) @@ -382,3 +407,84 @@ class AccountEdiFormat(models.Model): res.write({'extract_state': 'done'}) return res return self.env['account.move'] + + #################################################### + # Import helpers + #################################################### + + def _find_value(self, xpath, xml_element, namespaces=None): + element = xml_element.xpath(xpath, namespaces=namespaces) + return element[0].text if element else None + + def _retrieve_partner(self, name=None, phone=None, mail=None, vat=None): + '''Search all partners and find one that matches one of the parameters. + + :param name: The name of the partner. + :param phone: The phone or mobile of the partner. + :param mail: The mail of the partner. + :param vat: The vat number of the partner. + :returns: A partner or an empty recordset if not found. + ''' + domains = [] + for value, domain in ( + (name, [('name', 'ilike', name)]), + (phone, expression.OR([[('phone', '=', phone)], [('mobile', '=', phone)]])), + (mail, [('email', '=', mail)]), + (vat, [('vat', 'like', vat)]), + ): + if value is not None: + domains.append(domain) + + domain = expression.OR(domains) + return self.env['res.partner'].search(domain, limit=1) + + def _retrieve_product(self, name=None, default_code=None, barcode=None): + '''Search all products and find one that matches one of the parameters. + + :param name: The name of the product. + :param default_code: The default_code of the product. + :param barcode: The barcode of the product. + :returns: A product or an empty recordset if not found. + ''' + domains = [] + for value, domain in ( + (name, ('name', 'ilike', name)), + (default_code, ('default_code', '=', default_code)), + (barcode, ('barcode', '=', barcode)), + ): + if value is not None: + domains.append([domain]) + + domain = expression.OR(domains) + return self.env['product.product'].search(domain, limit=1) + + def _retrieve_tax(self, amount, type_tax_use): + '''Search all taxes and find one that matches all of the parameters. + + :param amount: The amount of the tax. + :param type_tax_use: The type of the tax. + :returns: A tax or an empty recordset if not found. + ''' + domains = [ + [('amount', '=', float(amount))], + [('type_tax_use', '=', type_tax_use)] + ] + + return self.env['account.tax'].search(expression.AND(domains), order='sequence ASC', limit=1) + + def _retrieve_currency(self, code): + '''Search all currencies and find one that matches the code. + + :param code: The code of the currency. + :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) diff --git a/addons/account_edi/models/account_move.py b/addons/account_edi/models/account_move.py index 46d8e1d67b7..62855e5e49f 100644 --- a/addons/account_edi/models/account_move.py +++ b/addons/account_edi/models/account_move.py @@ -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' diff --git a/addons/account_edi/tests/common.py b/addons/account_edi/tests/common.py index 0a560c1bb81..37c7eb3702e 100644 --- a/addons/account_edi/tests/common.py +++ b/addons/account_edi/tests/common.py @@ -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)) diff --git a/addons/account_edi/tests/test_edi.py b/addons/account_edi/tests/test_edi.py index 9d15b5ffec4..02bde5f8d12 100644 --- a/addons/account_edi/tests/test_edi.py +++ b/addons/account_edi/tests/test_edi.py @@ -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() diff --git a/addons/account_edi/views/account_move_views.xml b/addons/account_edi/views/account_move_views.xml index b52ca353e0f..34debc4fb85 100644 --- a/addons/account_edi/views/account_move_views.xml +++ b/addons/account_edi/views/account_move_views.xml @@ -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> diff --git a/addons/account_edi_extended/__init__.py b/addons/account_edi_extended/__init__.py new file mode 100644 index 00000000000..de362ffd485 --- /dev/null +++ b/addons/account_edi_extended/__init__.py @@ -0,0 +1,13 @@ +# -*- encoding: utf-8 -*- +from . import models + + +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. + ''' + from odoo import api, SUPERUSER_ID + + env = api.Environment(cr, SUPERUSER_ID, {}) + env['account.edi.document'].search([('error', '!=', False)]).write({'blocking_level': 'warning'}) diff --git a/addons/account_edi_extended/__manifest__.py b/addons/account_edi_extended/__manifest__.py new file mode 100644 index 00000000000..007eb4edcc6 --- /dev/null +++ b/addons/account_edi_extended/__manifest__.py @@ -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', +} diff --git a/addons/account_edi_extended/i18n/account_edi_extended.pot b/addons/account_edi_extended/i18n/account_edi_extended.pot new file mode 100644 index 00000000000..3a1c1597702 --- /dev/null +++ b/addons/account_edi_extended/i18n/account_edi_extended.pot @@ -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 "" diff --git a/addons/account_edi_extended/models/__init__.py b/addons/account_edi_extended/models/__init__.py new file mode 100644 index 00000000000..e8ed2ffb3b1 --- /dev/null +++ b/addons/account_edi_extended/models/__init__.py @@ -0,0 +1,3 @@ +from . import account_edi_document +from . import account_move +from . import account_payment diff --git a/addons/account_edi_extended/models/account_edi_document.py b/addons/account_edi_extended/models/account_edi_document.py new file mode 100644 index 00000000000..0894b13e872 --- /dev/null +++ b/addons/account_edi_extended/models/account_edi_document.py @@ -0,0 +1,18 @@ +# -*- 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.") + diff --git a/addons/account_edi_extended/models/account_move.py b/addons/account_edi_extended/models/account_move.py new file mode 100644 index 00000000000..4f81822a42e --- /dev/null +++ b/addons/account_edi_extended/models/account_move.py @@ -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'}) diff --git a/addons/account_edi_extended/models/account_payment.py b/addons/account_edi_extended/models/account_payment.py new file mode 100644 index 00000000000..558c4aaa8a5 --- /dev/null +++ b/addons/account_edi_extended/models/account_payment.py @@ -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() diff --git a/addons/account_edi_extended/tests/__init__.py b/addons/account_edi_extended/tests/__init__.py new file mode 100644 index 00000000000..eea5d7edcd2 --- /dev/null +++ b/addons/account_edi_extended/tests/__init__.py @@ -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 diff --git a/addons/account_edi_extended/tests/common.py b/addons/account_edi_extended/tests/common.py new file mode 100644 index 00000000000..3bb2ff6fd7f --- /dev/null +++ b/addons/account_edi_extended/tests/common.py @@ -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 diff --git a/addons/account_edi_extended/tests/test_edi.py b/addons/account_edi_extended/tests/test_edi.py new file mode 100644 index 00000000000..164a6c88b5d --- /dev/null +++ b/addons/account_edi_extended/tests/test_edi.py @@ -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, _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) diff --git a/addons/account_edi_extended/views/account_edi_document_views.xml b/addons/account_edi_extended/views/account_edi_document_views.xml new file mode 100644 index 00000000000..965de176574 --- /dev/null +++ b/addons/account_edi_extended/views/account_edi_document_views.xml @@ -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> diff --git a/addons/account_edi_extended/views/account_move_views.xml b/addons/account_edi_extended/views/account_move_views.xml new file mode 100644 index 00000000000..02ba1f39b35 --- /dev/null +++ b/addons/account_edi_extended/views/account_move_views.xml @@ -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> diff --git a/addons/account_edi_extended/views/account_payment_views.xml b/addons/account_edi_extended/views/account_payment_views.xml new file mode 100644 index 00000000000..8a4af21ee18 --- /dev/null +++ b/addons/account_edi_extended/views/account_payment_views.xml @@ -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> |
