diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/account_edi/models | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/account_edi/models')
| -rw-r--r-- | addons/account_edi/models/__init__.py | 10 | ||||
| -rw-r--r-- | addons/account_edi/models/account_edi_document.py | 252 | ||||
| -rw-r--r-- | addons/account_edi/models/account_edi_format.py | 552 | ||||
| -rw-r--r-- | addons/account_edi/models/account_journal.py | 53 | ||||
| -rw-r--r-- | addons/account_edi/models/account_move.py | 290 | ||||
| -rw-r--r-- | addons/account_edi/models/account_payment.py | 11 | ||||
| -rw-r--r-- | addons/account_edi/models/ir_actions_report.py | 16 | ||||
| -rw-r--r-- | addons/account_edi/models/ir_attachment.py | 15 | ||||
| -rw-r--r-- | addons/account_edi/models/mail_template.py | 35 |
9 files changed, 1234 insertions, 0 deletions
diff --git a/addons/account_edi/models/__init__.py b/addons/account_edi/models/__init__.py new file mode 100644 index 00000000..1d924dbd --- /dev/null +++ b/addons/account_edi/models/__init__.py @@ -0,0 +1,10 @@ +# -*- encoding: utf-8 -*- + +from . import account_move +from . import account_journal +from . import account_edi_format +from . import account_edi_document +from . import account_payment +from . import ir_actions_report +from . import mail_template +from . import ir_attachment diff --git a/addons/account_edi/models/account_edi_document.py b/addons/account_edi/models/account_edi_document.py new file mode 100644 index 00000000..afd58a07 --- /dev/null +++ b/addons/account_edi/models/account_edi_document.py @@ -0,0 +1,252 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +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 + +_logger = logging.getLogger(__name__) + + +class AccountEdiDocument(models.Model): + _name = 'account.edi.document' + _description = 'Electronic Document for an account.move' + + # == Stored fields == + move_id = fields.Many2one('account.move', required=True, ondelete='cascade') + 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(help='The text of the last error that happened during Electronic Invoice operation.') + + # == Not stored fields == + name = fields.Char(related='attachment_id.name') + edi_format_name = fields.Char(string='Format Name', related='edi_format_id.name') + + _sql_constraints = [ + ( + 'unique_edi_document_by_move_by_format', + 'UNIQUE(edi_format_id, move_id)', + 'Only one edi document by move by format', + ), + ] + + 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_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 (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 ? + """ + + # 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): + doc_type = 'invoice' + elif move.payment_id or move.statement_line_id: + doc_type = 'payment' + else: + continue + + 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'] + for document in documents: + move = document.move_id + move_result = edi_result.get(move, {}) + if move_result.get('attachment'): + old_attachment = document.attachment_id + 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'}) + document.write(values) + if not old_attachment.res_model or not old_attachment.res_id: + attachments_to_unlink |= old_attachment + else: + 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. + attachments_to_unlink.unlink() + + def _postprocess_cancel_edi_results(documents, edi_result): + invoice_ids_to_cancel = set() # Avoid duplicates + attachments_to_unlink = self.env['ir.attachment'] + for document in documents: + move = document.move_id + move_result = edi_result.get(move, {}) + 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': + # The user requested a cancellation of the EDI and it has been approved. Then, the invoice + # can be safely cancelled. + invoice_ids_to_cancel.add(move.id) + + if not old_attachment.res_model or not old_attachment.res_id: + attachments_to_unlink |= old_attachment + + 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)) + invoices.button_draft() + invoices.button_cancel() + + # 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. + attachments_to_unlink.unlink() + + test_mode = self._context.get('edi_test_mode', False) + + 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(self._convert_to_old_jobs_format(jobs)) + + 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() + jobs = jobs[0:job_count or len(jobs)] + for documents, doc_type in jobs: + move_to_lock = documents.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)]) + self._cr.execute('SELECT * FROM account_move WHERE id IN %s FOR UPDATE NOWAIT', [tuple(move_to_lock.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 new file mode 100644 index 00000000..1dce2c58 --- /dev/null +++ b/addons/account_edi/models/account_edi_format.py @@ -0,0 +1,552 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, fields, api +from odoo.tools.pdf import OdooPdfFileReader, OdooPdfFileWriter +from odoo.osv import expression +from odoo.tools import html_escape + +from lxml import etree +import base64 +import io +import logging +import pathlib + +_logger = logging.getLogger(__name__) + + +class AccountEdiFormat(models.Model): + _name = 'account.edi.format' + _description = 'EDI format' + + name = fields.Char() + code = fields.Char(required=True) + + _sql_constraints = [ + ('unique_code', 'unique (code)', 'This code already exists') + ] + + + #################################################### + # Low-level methods + #################################################### + + @api.model_create_multi + def create(self, vals_list): + edi_formats = super().create(vals_list) + + # activate by default on journal + journals = self.env['account.journal'].search([]) + for journal in journals: + for edi_format in edi_formats: + if edi_format._is_compatible_with_journal(journal): + journal.edi_format_ids += edi_format + + # activate cron + if any(edi_format._needs_web_services() for edi_format in edi_formats): + self.env.ref('account_edi.ir_cron_edi_network').active = True + + return edi_formats + + #################################################### + # Export method to override based on EDI Format + #################################################### + + def _is_required_for_invoice(self, invoice): + """ Indicate if this EDI must be generated for the invoice passed as parameter. + + :param invoice: An account.move having the invoice type. + :returns: True if the EDI must be generated, False otherwise. + """ + # TO OVERRIDE + self.ensure_one() + return True + + def _is_required_for_payment(self, payment): + """ Indicate if this EDI must be generated for the payment passed as parameter. + + :param payment: An account.move linked to either an account.payment, either an account.bank.statement.line. + :returns: True if the EDI must be generated, False otherwise. + """ + # TO OVERRIDE + self.ensure_one() + return False + + def _needs_web_services(self): + """ Indicate if the EDI must be generated asynchronously through to some web services. + + :return: True if such a web service is available, False otherwise. + """ + self.ensure_one() + return False + + def _is_compatible_with_journal(self, journal): + """ Indicate if the EDI format should appear on the journal passed as parameter to be selected by the user. + If True, this EDI format will be selected by default on the journal. + + :param journal: The journal. + :returns: True if this format can be enabled by default on the journal, False otherwise. + """ + # TO OVERRIDE + self.ensure_one() + return journal.type == 'sale' + + def _is_embedding_to_invoice_pdf_needed(self): + """ Indicate if the EDI must be embedded inside the PDF report. + + :returns: True if the documents need to be embedded, False otherwise. + """ + # TO OVERRIDE + return False + + def _get_embedding_to_invoice_pdf_values(self, invoice): + """ Get the values to embed to pdf. + + :returns: A dictionary {'name': name, 'datas': datas} or False if there are no values to embed. + * name: The name of the file. + * datas: The bytes ot the file. + """ + self.ensure_one() + attachment = invoice._get_edi_attachment(self) + if not attachment or not self._is_embedding_to_invoice_pdf_needed(): + return False + datas = base64.b64decode(attachment.with_context(bin_size=False).datas) + return {'name': attachment.name, 'datas': datas} + + 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. + + :returns: True if batching is supported, False otherwise. + """ + # 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). + + :param invoices: A list of invoices to post. + :param test_mode: A flag indicating the EDI should only simulate the EDI without sending data. + :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() + return {} + + def _cancel_invoice_edi(self, invoices, test_mode=False): + """Calls the web services to cancel the invoice of this document. + + :param invoices: A list of invoices to cancel. + :param test_mode: A flag indicating the EDI should only simulate the EDI without sending data. + :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() + return {invoice: {'success': True} for invoice in invoices} # By default, cancel succeeds doing nothing. + + def _post_payment_edi(self, payments, test_mode=False): + """ Create the file content representing the payment (and calls web services if necessary). + + :param payments: The payments to post. + :param test_mode: A flag indicating the EDI should only simulate the EDI without sending data. + :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() + return {} + + def _cancel_payment_edi(self, payments, test_mode=False): + """Calls the web services to cancel the payment of this document. + + :param payments: A list of payments to cancel. + :param test_mode: A flag indicating the EDI should only simulate the EDI without sending data. + :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() + return {payment: {'success': True} for payment in payments} # By default, cancel succeeds doing nothing. + + #################################################### + # Import methods to override based on EDI Format + #################################################### + + def _create_invoice_from_xml_tree(self, filename, tree): + """ Create a new invoice with the data inside the xml. + + :param filename: The name of the xml. + :param tree: The tree of the xml to import. + :returns: The created invoice. + """ + # TO OVERRIDE + self.ensure_one() + return self.env['account.move'] + + def _update_invoice_from_xml_tree(self, filename, tree, invoice): + """ Update an existing invoice with the data inside the xml. + + :param filename: The name of the xml. + :param tree: The tree of the xml to import. + :param invoice: The invoice to update. + :returns: The updated invoice. + """ + # TO OVERRIDE + self.ensure_one() + return self.env['account.move'] + + def _create_invoice_from_pdf_reader(self, filename, reader): + """ Create a new invoice with the data inside a pdf. + + :param filename: The name of the pdf. + :param reader: The OdooPdfFileReader of the pdf to import. + :returns: The created invoice. + """ + # TO OVERRIDE + self.ensure_one() + + return self.env['account.move'] + + def _update_invoice_from_pdf_reader(self, filename, reader, invoice): + """ Update an existing invoice with the data inside the pdf. + + :param filename: The name of the pdf. + :param reader: The OdooPdfFileReader of the pdf to import. + :param invoice: The invoice to update. + :returns: The updated invoice. + """ + # TO OVERRIDE + self.ensure_one() + return self.env['account.move'] + + def _create_invoice_from_binary(self, filename, content, extension): + """ Create a new invoice with the data inside a binary file. + + :param filename: The name of the file. + :param content: The content of the binary file. + :param extension: The extensions as a string. + :returns: The created invoice. + """ + # TO OVERRIDE + self.ensure_one() + return self.env['account.move'] + + def _update_invoice_from_binary(self, filename, content, extension, invoice): + """ Update an existing invoice with the data inside a binary file. + + :param filename: The name of the file. + :param content: The content of the binary file. + :param extension: The extensions as a string. + :param invoice: The invoice to update. + :returns: The updated invoice. + """ + # TO OVERRIDE + self.ensure_one() + return self.env['account.move'] + + #################################################### + # Export Internal methods (not meant to be overridden) + #################################################### + + def _embed_edis_to_pdf(self, pdf_content, invoice): + """ Create the EDI document of the invoice and embed it in the pdf_content. + + :param pdf_content: the bytes representing the pdf to add the EDIs to. + :param invoice: the invoice to generate the EDI from. + :returns: the same pdf_content with the EDI of the invoice embed in it. + """ + attachments = [] + for edi_format in self.filtered(lambda edi_format: edi_format._is_embedding_to_invoice_pdf_needed()): + attach = edi_format._get_embedding_to_invoice_pdf_values(invoice) + if attach: + attachments.append(attach) + + if attachments: + # Add the attachments to the pdf file + reader_buffer = io.BytesIO(pdf_content) + reader = OdooPdfFileReader(reader_buffer, strict=False) + writer = OdooPdfFileWriter() + writer.cloneReaderDocumentRoot(reader) + for vals in attachments: + writer.addAttachment(vals['name'], vals['datas']) + buffer = io.BytesIO() + writer.write(buffer) + pdf_content = buffer.getvalue() + reader_buffer.close() + buffer.close() + return pdf_content + + #################################################### + # Import Internal methods (not meant to be overridden) + #################################################### + + def _decode_xml(self, filename, content): + """Decodes an xml into a list of one dictionary representing an attachment. + + :param filename: The name of the xml. + :param content: The bytes representing the xml. + :returns: A list with a dictionary. + * filename: The name of the attachment. + * content: The content of the attachment. + * type: The type of the attachment. + * xml_tree: The tree of the xml if type is xml. + """ + to_process = [] + try: + xml_tree = etree.fromstring(content) + except Exception as e: + _logger.exception("Error when converting the xml content to etree: %s" % e) + return to_process + if len(xml_tree): + to_process.append({ + 'filename': filename, + 'content': content, + 'type': 'xml', + 'xml_tree': xml_tree, + }) + return to_process + + def _decode_pdf(self, filename, content): + """Decodes a pdf and unwrap sub-attachment into a list of dictionary each representing an attachment. + + :param filename: The name of the pdf. + :param content: The bytes representing the pdf. + :returns: A list of dictionary for each attachment. + * filename: The name of the attachment. + * content: The content of the attachment. + * type: The type of the attachment. + * xml_tree: The tree of the xml if type is xml. + * pdf_reader: The pdf_reader if type is pdf. + """ + to_process = [] + try: + buffer = io.BytesIO(content) + pdf_reader = OdooPdfFileReader(buffer, strict=False) + except Exception as e: + # Malformed pdf + _logger.exception("Error when reading the pdf: %s" % e) + return to_process + + # Process embedded files. + try: + for xml_name, content in pdf_reader.getAttachments(): + to_process.extend(self._decode_xml(xml_name, content)) + except NotImplementedError as e: + _logger.warning("Unable to access the attachments of %s. Tried to decrypt it, but %s." % (filename, e)) + + # Process the pdf itself. + to_process.append({ + 'filename': filename, + 'content': content, + 'type': 'pdf', + 'pdf_reader': pdf_reader, + }) + + return to_process + + def _decode_binary(self, filename, content): + """Decodes any file into a list of one dictionary representing an attachment. + This is a fallback for all files that are not decoded by other methods. + + :param filename: The name of the file. + :param content: The bytes representing the file. + :returns: A list with a dictionary. + * filename: The name of the attachment. + * content: The content of the attachment. + * type: The type of the attachment. + """ + return [{ + 'filename': filename, + 'extension': ''.join(pathlib.Path(filename).suffixes), + 'content': content, + 'type': 'binary', + }] + + def _decode_attachment(self, attachment): + """Decodes an ir.attachment and unwrap sub-attachment into a list of dictionary each representing an attachment. + + :param attachment: An ir.attachment record. + :returns: A list of dictionary for each attachment. + * filename: The name of the attachment. + * content: The content of the attachment. + * type: The type of the attachment. + * xml_tree: The tree of the xml if type is xml. + * pdf_reader: The pdf_reader if type is pdf. + """ + content = base64.b64decode(attachment.with_context(bin_size=False).datas) + to_process = [] + + if 'pdf' in attachment.mimetype: + to_process.extend(self._decode_pdf(attachment.name, content)) + elif 'xml' in attachment.mimetype: + to_process.extend(self._decode_xml(attachment.name, content)) + else: + to_process.extend(self._decode_binary(attachment.name, content)) + + return to_process + + def _create_invoice_from_attachment(self, attachment): + """Decodes an ir.attachment to create an invoice. + + :param attachment: An ir.attachment record. + :returns: The invoice where to import data. + """ + for file_data in self._decode_attachment(attachment): + for edi_format in self: + res = False + try: + if file_data['type'] == 'xml': + res = edi_format._create_invoice_from_xml_tree(file_data['filename'], file_data['xml_tree']) + elif file_data['type'] == 'pdf': + res = edi_format._create_invoice_from_pdf_reader(file_data['filename'], file_data['pdf_reader']) + file_data['pdf_reader'].stream.close() + else: + res = edi_format._create_invoice_from_binary(file_data['filename'], file_data['content'], file_data['extension']) + except Exception as e: + _logger.exception("Error importing attachment \"%s\" as invoice with format \"%s\"", file_data['filename'], edi_format.name, str(e)) + if res: + if 'extract_state' in res: + # Bypass the OCR to prevent overwriting data when an EDI was succesfully imported. + # TODO : remove when we integrate the OCR to the EDI flow. + res.write({'extract_state': 'done'}) + return res + return self.env['account.move'] + + def _update_invoice_from_attachment(self, attachment, invoice): + """Decodes an ir.attachment to update an invoice. + + :param attachment: An ir.attachment record. + :returns: The invoice where to import data. + """ + for file_data in self._decode_attachment(attachment): + for edi_format in self: + res = False + try: + if file_data['type'] == 'xml': + res = edi_format._update_invoice_from_xml_tree(file_data['filename'], file_data['xml_tree'], invoice) + elif file_data['type'] == 'pdf': + res = edi_format._update_invoice_from_pdf_reader(file_data['filename'], file_data['pdf_reader'], invoice) + file_data['pdf_reader'].stream.close() + else: # file_data['type'] == 'binary' + res = edi_format._update_invoice_from_binary(file_data['filename'], file_data['content'], file_data['extension'], invoice) + except Exception as e: + _logger.exception("Error importing attachment \"%s\" as invoice with format \"%s\"", file_data['filename'], edi_format.name, str(e)) + if res: + if 'extract_state' in res: + # Bypass the OCR to prevent overwriting data when an EDI was succesfully imported. + # TODO : remove when we integrate the OCR to the EDI flow. + 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>' % html_escape(msg) for msg in errors) + return '%s<ul>%s</ul>' % (error_title, bullet_list_msg) diff --git a/addons/account_edi/models/account_journal.py b/addons/account_edi/models/account_journal.py new file mode 100644 index 00000000..a40a90b2 --- /dev/null +++ b/addons/account_edi/models/account_journal.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models, fields, _ +from odoo.exceptions import UserError + + +class AccountJournal(models.Model): + _inherit = 'account.journal' + + edi_format_ids = fields.Many2many(comodel_name='account.edi.format', + string='Electronic invoicing', + help='Send XML/EDI invoices', + domain="[('id', 'in', compatible_edi_ids)]", + compute='_compute_edi_format_ids', + readonly=False, store=True) + + compatible_edi_ids = fields.Many2many(comodel_name='account.edi.format', + compute='_compute_compatible_edi_ids', + help='EDI format that support moves in this journal') + + def write(self, vals): + # OVERRIDE + # Don't allow the user to deactivate an edi format having at least one document to be processed. + if vals.get('edi_format_ids'): + old_edi_format_ids = self.edi_format_ids + res = super().write(vals) + diff_edi_format_ids = old_edi_format_ids - self.edi_format_ids + documents = self.env['account.edi.document'].search([ + ('move_id.journal_id', 'in', self.ids), + ('edi_format_id', 'in', diff_edi_format_ids.ids), + ('state', 'in', ('to_cancel', 'to_send')), + ]) + if documents: + raise UserError(_('Cannot deactivate (%s) on this journal because not all documents are synchronized', ', '.join(documents.edi_format_id.mapped('display_name')))) + return res + else: + return super().write(vals) + + @api.depends('type', 'company_id', 'company_id.country_id') + def _compute_compatible_edi_ids(self): + edi_formats = self.env['account.edi.format'].search([]) + + for journal in self: + compatible_edis = edi_formats.filtered(lambda e: e._is_compatible_with_journal(journal)) + journal.compatible_edi_ids += compatible_edis + + @api.depends('type', 'company_id', 'company_id.country_id') + def _compute_edi_format_ids(self): + edi_formats = self.env['account.edi.format'].search([]) + + for journal in self: + journal.edi_format_ids += edi_formats.filtered(lambda e: e._is_compatible_with_journal(journal)) diff --git a/addons/account_edi/models/account_move.py b/addons/account_edi/models/account_move.py new file mode 100644 index 00000000..3faa9e14 --- /dev/null +++ b/addons/account_edi/models/account_move.py @@ -0,0 +1,290 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError + + +class AccountMove(models.Model): + _inherit = 'account.move' + + edi_document_ids = fields.One2many( + comodel_name='account.edi.document', + inverse_name='move_id') + edi_state = fields.Selection( + selection=[('to_send', 'To Send'), ('sent', 'Sent'), ('to_cancel', 'To Cancel'), ('cancelled', 'Cancelled')], + string="Electronic invoicing", + store=True, + compute='_compute_edi_state', + help='The aggregated state of all the EDIs of this move') + edi_error_count = fields.Integer( + compute='_compute_edi_error_count', + help='How many EDIs are in error for this move ?') + edi_web_services_to_process = fields.Text( + compute='_compute_edi_web_services_to_process', + help="Technical field to display the documents that will be processed by the CRON") + edi_show_cancel_button = fields.Boolean( + compute='_compute_edi_show_cancel_button') + + @api.depends('edi_document_ids.state') + def _compute_edi_state(self): + for move in self: + all_states = set(move.edi_document_ids.filtered(lambda d: d.edi_format_id._needs_web_services()).mapped('state')) + if all_states == {'sent'}: + move.edi_state = 'sent' + elif all_states == {'cancelled'}: + move.edi_state = 'cancelled' + elif 'to_send' in all_states: + move.edi_state = 'to_send' + elif 'to_cancel' in all_states: + move.edi_state = 'to_cancel' + else: + move.edi_state = False + + @api.depends('edi_document_ids.error') + def _compute_edi_error_count(self): + for move in self: + move.edi_error_count = len(move.edi_document_ids.filtered(lambda d: d.error)) + + @api.depends( + 'edi_document_ids', + 'edi_document_ids.state', + 'edi_document_ids.edi_format_id', + 'edi_document_ids.edi_format_id.name') + def _compute_edi_web_services_to_process(self): + for move in self: + to_process = move.edi_document_ids.filtered(lambda d: d.state in ['to_send', 'to_cancel']) + 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('restrict_mode_hash_table', 'state') + def _compute_show_reset_to_draft_button(self): + # OVERRIDE + super()._compute_show_reset_to_draft_button() + + for move in self: + for doc in move.edi_document_ids: + if doc.edi_format_id._needs_web_services() \ + and doc.attachment_id \ + and doc.state in ('sent', 'to_cancel') \ + and move.is_invoice(include_receipts=True) \ + and doc.edi_format_id._is_required_for_invoice(move): + move.show_reset_to_draft_button = False + break + + @api.depends( + 'state', + 'edi_document_ids.state', + 'edi_document_ids.attachment_id') + def _compute_edi_show_cancel_button(self): + for move in self: + if move.state != 'posted': + move.edi_show_cancel_button = False + continue + + move.edi_show_cancel_button = any([doc.edi_format_id._needs_web_services() + and doc.attachment_id + and doc.state == 'sent' + and move.is_invoice(include_receipts=True) + and doc.edi_format_id._is_required_for_invoice(move) + for doc in move.edi_document_ids]) + + #################################################### + # Export Electronic Document + #################################################### + + def _update_payments_edi_documents(self): + ''' Update the edi documents linked to the current journal entries. These journal entries must be linked to an + account.payment of an account.bank.statement.line. This additional method is needed because the payment flow is + not the same as the invoice one. Indeed, the edi documents must be updated when the reconciliation with some + invoices is changing. + ''' + edi_document_vals_list = [] + for payment in self: + edi_formats = payment._get_reconciled_invoices().journal_id.edi_format_ids + payment.edi_document_ids.edi_format_id + edi_formats = self.env['account.edi.format'].browse(edi_formats.ids) # Avoid duplicates + for edi_format in edi_formats: + existing_edi_document = payment.edi_document_ids.filtered(lambda x: x.edi_format_id == edi_format) + + if edi_format._is_required_for_payment(payment): + if existing_edi_document: + existing_edi_document.write({ + 'state': 'to_send', + 'error': False, + 'blocking_level': False, + }) + else: + edi_document_vals_list.append({ + 'edi_format_id': edi_format.id, + 'move_id': payment.id, + 'state': 'to_send', + }) + elif existing_edi_document: + existing_edi_document.write({ + 'state': False, + 'error': False, + 'blocking_level': False, + }) + + self.env['account.edi.document'].create(edi_document_vals_list) + self.edi_document_ids._process_documents_no_web_services() + + def _post(self, soft=True): + # OVERRIDE + # Set the electronic document to be posted and post immediately for synchronous formats. + posted = super()._post(soft=soft) + + edi_document_vals_list = [] + for move in posted: + for edi_format in move.journal_id.edi_format_ids: + 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({ + 'state': 'to_send', + 'attachment_id': False, + }) + else: + edi_document_vals_list.append({ + 'edi_format_id': edi_format.id, + 'move_id': move.id, + 'state': 'to_send', + }) + + self.env['account.edi.document'].create(edi_document_vals_list) + posted.edi_document_ids._process_documents_no_web_services() + return posted + + def button_cancel(self): + # OVERRIDE + # Set the electronic document to be canceled and cancel immediately for synchronous formats. + res = super().button_cancel() + + self.edi_document_ids.filtered(lambda doc: doc.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 + + def button_draft(self): + # OVERRIDE + for move in self: + if move.edi_show_cancel_button: + raise UserError(_( + "You can't edit the following journal entry %s because an electronic document has already been " + "sent. Please use the 'Request EDI Cancellation' button instead." + ) % move.display_name) + + res = super().button_draft() + + self.edi_document_ids.write({'state': False, 'error': False, 'blocking_level': False}) + + return res + + def button_cancel_posted_moves(self): + '''Mark the edi.document related to this move to be canceled. + ''' + to_cancel_documents = self.env['account.edi.document'] + for move in self: + is_move_marked = False + for doc in move.edi_document_ids: + if doc.edi_format_id._needs_web_services() \ + and doc.attachment_id \ + and doc.state == 'sent' \ + and move.is_invoice(include_receipts=True) \ + and doc.edi_format_id._is_required_for_invoice(move): + to_cancel_documents |= doc + is_move_marked = True + if is_move_marked: + move.message_post(body=_("A cancellation of the EDI has been requested.")) + + to_cancel_documents.write({'state': 'to_cancel', 'error': False, 'blocking_level': False}) + + def _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 + #################################################### + + def _get_create_invoice_from_attachment_decoders(self): + # OVERRIDE + res = super()._get_create_invoice_from_attachment_decoders() + res.append((10, self.env['account.edi.format'].search([])._create_invoice_from_attachment)) + return res + + def _get_update_invoice_from_attachment_decoders(self, invoice): + # OVERRIDE + res = super()._get_update_invoice_from_attachment_decoders(invoice) + res.append((10, self.env['account.edi.format'].search([])._update_invoice_from_attachment)) + return res + + #################################################### + # Business operations + #################################################### + + def action_process_edi_web_services(self): + 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') + docs._process_documents_web_services(with_commit=False) + +class AccountMoveLine(models.Model): + _inherit = 'account.move.line' + + #################################################### + # Export Electronic Document + #################################################### + + def reconcile(self): + # OVERRIDE + # In some countries, the payments must be sent to the government under some condition. One of them could be + # there is at least one reconciled invoice to the payment. Then, we need to update the state of the edi + # documents during the reconciliation. + all_lines = self + self.matched_debit_ids.debit_move_id + self.matched_credit_ids.credit_move_id + payments = all_lines.move_id.filtered(lambda move: move.payment_id or move.statement_line_id) + + invoices_per_payment_before = {pay: pay._get_reconciled_invoices() for pay in payments} + res = super().reconcile() + invoices_per_payment_after = {pay: pay._get_reconciled_invoices() for pay in payments} + + changed_payments = self.env['account.move'] + for payment, invoices_after in invoices_per_payment_after.items(): + invoices_before = invoices_per_payment_before[payment] + + if set(invoices_after.ids) != set(invoices_before.ids): + changed_payments |= payment + changed_payments._update_payments_edi_documents() + + return res + + def remove_move_reconcile(self): + # OVERRIDE + # When a payment has been sent to the government, it usually contains some information about reconciled + # invoices. If the user breaks a reconciliation, the related payments must be cancelled properly and then, a new + # electronic document must be generated. + all_lines = self + self.matched_debit_ids.debit_move_id + self.matched_credit_ids.credit_move_id + payments = all_lines.move_id.filtered(lambda move: move.payment_id or move.statement_line_id) + + invoices_per_payment_before = {pay: pay._get_reconciled_invoices() for pay in payments} + res = super().remove_move_reconcile() + invoices_per_payment_after = {pay: pay._get_reconciled_invoices() for pay in payments} + + changed_payments = self.env['account.move'] + for payment, invoices_after in invoices_per_payment_after.items(): + invoices_before = invoices_per_payment_before[payment] + + if set(invoices_after.ids) != set(invoices_before.ids): + changed_payments |= payment + changed_payments._update_payments_edi_documents() + + return res diff --git a/addons/account_edi/models/account_payment.py b/addons/account_edi/models/account_payment.py new file mode 100644 index 00000000..8b8a691d --- /dev/null +++ b/addons/account_edi/models/account_payment.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, fields, api, _ + + +class AccountPayment(models.Model): + _inherit = 'account.payment' + + def action_process_edi_web_services(self): + return self.move_id.action_process_edi_web_services() diff --git a/addons/account_edi/models/ir_actions_report.py b/addons/account_edi/models/ir_actions_report.py new file mode 100644 index 00000000..9dcba740 --- /dev/null +++ b/addons/account_edi/models/ir_actions_report.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api, _ + + +class IrActionsReport(models.Model): + _inherit = 'ir.actions.report' + + def _post_pdf(self, save_in_attachment, pdf_content=None, res_ids=None): + # OVERRIDE to embed some EDI documents inside the PDF. + if self.model == 'account.move' and res_ids and len(res_ids) == 1 and pdf_content: + invoice = self.env['account.move'].browse(res_ids) + if invoice.is_sale_document() and invoice.state != 'draft': + pdf_content = invoice.journal_id.edi_format_ids._embed_edis_to_pdf(pdf_content, invoice) + + return super(IrActionsReport, self)._post_pdf(save_in_attachment, pdf_content=pdf_content, res_ids=res_ids) diff --git a/addons/account_edi/models/ir_attachment.py b/addons/account_edi/models/ir_attachment.py new file mode 100644 index 00000000..e49e6946 --- /dev/null +++ b/addons/account_edi/models/ir_attachment.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields, _ +from odoo.exceptions import UserError + + +class IrAttachment(models.Model): + _inherit = 'ir.attachment' + + def unlink(self): + # OVERRIDE + linked_edi_documents = self.env['account.edi.document'].search([('attachment_id', 'in', self.ids)]) + linked_edi_formats_ws = linked_edi_documents.edi_format_id.filtered(lambda edi_format: edi_format._needs_web_services()) + if linked_edi_formats_ws: + raise UserError(_("You can't unlink an attachment being an EDI document sent to the government.")) + return super().unlink() diff --git a/addons/account_edi/models/mail_template.py b/addons/account_edi/models/mail_template.py new file mode 100644 index 00000000..7c48fce3 --- /dev/null +++ b/addons/account_edi/models/mail_template.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- + +from odoo import api, models + + +class MailTemplate(models.Model): + _inherit = "mail.template" + + def generate_email(self, res_ids, fields): + res = super().generate_email(res_ids, fields) + + multi_mode = True + if isinstance(res_ids, int): + res_ids = [res_ids] + multi_mode = False + + if self.model not in ['account.move', 'account.payment']: + return res + + records = self.env[self.model].browse(res_ids) + for record in records: + record_data = (res[record.id] if multi_mode else res) + for doc in record.edi_document_ids: + + # The EDI format will be embedded directly inside the PDF and then, don't need to be added to the + # wizard. + if doc.edi_format_id._is_embedding_to_invoice_pdf_needed(): + continue + + attachment = doc.attachment_id + if attachment: + record_data.setdefault('attachments', []) + record_data['attachments'].append((attachment.name, attachment.datas)) + + return res |
