Skip to content

Commit

Permalink
[ADD] account_edi: generic module to manage EDI (factur-x)
Browse files Browse the repository at this point in the history
- account_edi is the base module to manage EDIs. It manages the generic worflow of import/export and defines the methods for import/export that should be overridden by each types of EDI (in different modules).
- On export, the files are added as attachment and can be embeded into pdf (format specific).
- On export, the exported format are set on the journal to allow different settings based on journal (different companies, countries, etc).
- On import, depending on the import method (from message_post or upload button in invoice tree view) a new invoice is created or an existing one is updated (only allowed draft invoices with no invoice lines).
- Adapted factur-x
  • Loading branch information
bfr-o committed May 29, 2020
1 parent 0226901 commit 913f22f
Show file tree
Hide file tree
Showing 50 changed files with 616 additions and 1,597 deletions.
14 changes: 11 additions & 3 deletions addons/account/models/account_journal.py
Original file line number Diff line number Diff line change
Expand Up @@ -546,10 +546,8 @@ def create_invoice_from_attachment(self, attachment_ids=[]):

invoices = self.env['account.move']
for attachment in attachments:
invoice = self.env['account.move'].create({})
attachment.write({'res_model': 'mail.compose.message'})
invoice.message_post(attachment_ids=[attachment.id])
invoices += invoice
invoices += self._create_invoice_from_single_attachment(attachment)

action_vals = {
'name': _('Generated Documents'),
Expand All @@ -565,6 +563,16 @@ def create_invoice_from_attachment(self, attachment_ids=[]):
action_vals['view_mode'] = 'tree,form'
return action_vals

def _create_invoice_from_single_attachment(self, attachment):
""" Creates an invoice and post the attachment. If the related modules
are installed, it will trigger OCR or the import from the EDI.
:returns: the created invoice.
"""
invoice = self.env['account.move'].create({})
invoice.message_post(attachment_ids=[attachment.id])
return invoice

def _create_secure_sequence(self, sequence_fields):
"""This function creates a no_gap sequence on each journal in self that will ensure
a unique number is given to all posted account.move in such a way that we can always
Expand Down
3 changes: 2 additions & 1 deletion addons/account/wizard/account_invoice_send.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ def _compute_invoice_without_email(self):

def _send_email(self):
if self.is_email:
self.composer_id.send_mail()
# with_context : we don't want to reimport the file we just exported.
self.composer_id.with_context(no_new_invoice=True).send_mail()
if self.env.context.get('mark_invoice_as_sent'):
#Salesman send posted invoice, without the right to write
#but they should have the right to change this flag
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# -*- encoding: utf-8 -*-

from . import models
from . import wizard
24 changes: 24 additions & 0 deletions addons/account_edi/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
{
'name' : 'Import/Export Invoices From XML/PDF',
'description':"""
Electronic Data Interchange
=======================================
EDI is the electronic interchange of business information using a standardized format.
This is the base module for import and export of invoices in various EDI formats, and the
the transmission of said documents to various parties involved in the exchange (other company,
governements, etc.)
""",
'version' : '1.0',
'category': 'Accounting/Accounting',
'depends' : ['account'],
'data': [
'security/ir.model.access.csv',
'wizard/account_invoice_send_views.xml',
'views/account_journal_views.xml'
],
'installable': True,
'application': False,
'auto_install': True,
}
8 changes: 8 additions & 0 deletions addons/account_edi/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# -*- encoding: utf-8 -*-

from . import account_move
from . import account_journal
from . import account_edi_format
from . import ir_actions_report
from . import ir_attachment
from . import mail_template
300 changes: 300 additions & 0 deletions addons/account_edi/models/account_edi_format.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.

from odoo import models, fields, api
from odoo.exceptions import UserError
from odoo.tools.pdf import OdooPdfFileReader, OdooPdfFileWriter

from lxml import etree
import base64
import io
import logging

_logger = logging.getLogger(__name__)


class AccountEdiFormat(models.Model):
_name = 'account.edi.format'
_description = 'EDI format'

name = fields.Char()
code = fields.Char()
hide_on_journal = fields.Selection([('import_export', 'Import/Export'), ('import', 'Import Only')], default='import_export', help='used to hide this EDI format on journals')

_sql_constraints = [
('unique_code', 'unique (code)', 'This code already exists')
]

####################################################
# Export method to override based on EDI Format
####################################################

def _export_invoice_to_attachment(self, invoice):
""" Create the file content representing the invoice.
:param invoice: the invoice to encode.
:returns: a dictionary (values are compatible to create an ir.attachment)
* name : the name of the file
* datas : the content of the file,
* res_model : 'account.move',
* res_id: the id of invoice
* mimetype : the mimetype of the attachment
"""
# TO OVERRIDE
self.ensure_one()
return False

def _export_invoice_to_embed_to_pdf(self, pdf_content, invoice):
""" Create the file content representing the invoice when it's destined
to be embed into a pdf.
- default: creates the default EDI document (_export_invoice_to_attachment).
- Should return False if this EDI format should not be embedded.
- Should be overriden only if a specific behavior (for example,
include the pdf content inside the file).
:param pdf_content: the pdf before any EDI format was added.
:param invoice: the invoice to add.
:returns: a dictionary or False if this EDI format must not be embedded to pdf.
* name : the name of the file
* datas : the content of the file,
* res_model : 'account.move',
* res_id: the id of invoice
* mimetype : the mimetype of the attachment
"""
# TO OVERRIDE
self.ensure_one()
return self._export_invoice_to_attachment(invoice)

####################################################
# 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']

####################################################
# 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:
try:
vals = edi_format._export_invoice_to_embed_to_pdf(pdf_content, invoice)
except:
continue
if vals:
attachments.append(vals)

if attachments:
# Add the attachments to the pdf file
reader_buffer = io.BytesIO(pdf_content)
reader = OdooPdfFileReader(reader_buffer)
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

def _create_ir_attachments(self, invoice):
""" Create ir.attachment for the EDIs from invoice.
:param invoice: the invoice to generate the EDI from.
:returns: the newly created attachments.
"""
attachment_vals_list = []
for edi_format in self:
vals = edi_format._export_invoice_to_attachment(invoice)
if vals:
vals['datas'] = base64.encodebytes(vals['datas'])
vals['edi_format_id'] = edi_format._origin.id
attachment_vals_list.append(vals)
res = self.env['ir.attachment'].create(attachment_vals_list)
invoice.edi_document_ids |= res
return res

####################################################
# 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 attachment: The xml as a string.
: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.
* pdf_reader: The pdf_reader if type is pdf.
"""
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)
except Exception as e:
# Malformed pdf
_logger.exception("Error when reading the pdf: %s" % e)
return to_process

# Process embedded files.
for xml_name, content in pdf_reader.getAttachments():
to_process.extend(self._decode_xml(xml_name, content))

# Process the pdf itself.
to_process.append({
'filename': filename,
'content': content,
'type': 'pdf',
'pdf_reader': pdf_reader,
})

return to_process

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))

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
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()
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
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()
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']
Loading

0 comments on commit 913f22f

Please sign in to comment.