[IMP] account_edi: added flows to manage web-services and payments.

Added edi.documents representing an electronic document for a move and an edi.format.
A format can be asynchronous if it needs to call a web-service to generate the document, it will then be generated by the CRON (otherwise it's generated in post).
The formats can support payments if needed (can be generated immediately or by the CRON).
Added support for errors and related views.
Setting defaults format on a journal can be done automatically (based on a hook).
Added tests : xml comparaison with diff and helpers to test a EDI import/export

--task: 2247368
This commit is contained in:
Benjamin Frantzen (bfr)
2020-08-17 10:22:01 +00:00
committed by Laurent Smet
parent 34c4752eea
commit 013936261b
40 changed files with 1307 additions and 310 deletions
@@ -529,6 +529,7 @@ class AccountBankStatementLine(models.Model):
compute='_compute_is_reconciled',
help="Technical field indicating if the statement line is already reconciled.")
state = fields.Selection(related='statement_id.state', string='Status', readonly=True)
country_code = fields.Char(related='company_id.country_id.code')
# -------------------------------------------------------------------------
# HELPERS
+1
View File
@@ -101,6 +101,7 @@ class AccountJournal(models.Model):
currency_id = fields.Many2one('res.currency', help='The currency used to enter statement', string="Currency")
company_id = fields.Many2one('res.company', string='Company', required=True, readonly=True, index=True, default=lambda self: self.env.company,
help="Company related to this journal")
country_code = fields.Char(related='company_id.country_id.code', readonly=True)
refund_sequence = fields.Boolean(string='Dedicated Credit Note Sequence', help="Check this box if you don't want to share the same sequence for invoices and credit notes made from this journal", default=False)
sequence_override_regex = fields.Text(help="Technical field used to enforce complex sequence composition that the system would normally misunderstand.\n"\
+6
View File
@@ -306,6 +306,7 @@ class AccountMove(models.Model):
help="Technical field used to display a message when the invoice's accounting date is prior of the tax lock date.")
# Technical field to hide Reconciled Entries stat button
has_reconciled_entries = fields.Boolean(compute="_compute_has_reconciled_entries")
show_reset_to_draft_button = fields.Boolean(compute='_compute_show_reset_to_draft_button')
# ==== Hash Fields ====
restrict_mode_hash_table = fields.Boolean(related='journal_id.restrict_mode_hash_table')
@@ -1430,6 +1431,11 @@ class AccountMove(models.Model):
else:
move.tax_lock_date_message = False
@api.depends('restrict_mode_hash_table', 'state')
def _compute_show_reset_to_draft_button(self):
for move in self:
move.show_reset_to_draft_button = not move.restrict_mode_hash_table and move.state in ('posted', 'cancel')
# -------------------------------------------------------------------------
# BUSINESS MODELS SYNCHRONIZATION
# -------------------------------------------------------------------------
+1
View File
@@ -125,6 +125,7 @@ class AccountPayment(models.Model):
require_partner_bank_account = fields.Boolean(
compute='_compute_show_require_partner_bank',
help="Technical field used to know whether the field `partner_bank_id` needs to be required or not in the payments form views")
country_code = fields.Char(related='company_id.country_id.code')
_sql_constraints = [
(
+1
View File
@@ -92,6 +92,7 @@ class AccountTax(models.Model):
invoice_repartition_line_ids = fields.One2many(string="Repartition for Invoices", comodel_name="account.tax.repartition.line", inverse_name="invoice_tax_id", copy=True, help="Repartition when the tax is used on an invoice")
refund_repartition_line_ids = fields.One2many(string="Repartition for Refund Invoices", comodel_name="account.tax.repartition.line", inverse_name="refund_tax_id", copy=True, help="Repartition when the tax is used on a refund")
tax_fiscal_country_id = fields.Many2one(string='Fiscal Country', comodel_name='res.country', related='company_id.account_tax_fiscal_country_id', help="Technical field used to restrict the domain of account tags for tax repartition lines created for this tax.")
country_code = fields.Char(related='company_id.country_id.code', readonly=True)
_sql_constraints = [
('name_company_uniq', 'unique(name, company_id, type_tax_use, tax_scope)', 'Tax names must be unique !'),
-96
View File
@@ -1,96 +0,0 @@
# -*- coding: utf-8 -*-
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
import base64
from lxml import etree
class AccountTestEdiCommon(AccountTestInvoicingCommon):
def assertXmlTreeEqual(self, xml_tree, expected_xml_tree):
''' Compare two lxml.etree.
:param xml_tree: The current tree.
:param expected_xml_tree: The expected tree.
'''
def _turn_node_as_dict_hierarchy(node):
''' Turn the node as a python dictionary to be compared later with another one.
Allow to ignore the management of namespaces.
:param node: A node inside an xml tree.
:return: A python dictionary.
'''
tag_split = node.tag.split('}')
tag_wo_ns = tag_split[-1]
attrib_wo_ns = {k: v for k, v in node.attrib.items() if '}' not in k}
return {
'tag': tag_wo_ns,
'namespace': None if len(tag_split) < 2 else tag_split[0],
'text': (node.text or '').strip(),
'attrib': attrib_wo_ns,
'children': [_turn_node_as_dict_hierarchy(child_node) for child_node in node.getchildren()],
}
def assertNodeDictEqual(node_dict, expected_node_dict):
''' Compare nodes created by the `_turn_node_as_dict_hierarchy` method.
:param node_dict: The node to compare with.
:param expected_node_dict: The expected node.
'''
# Check tag.
self.assertEqual(node_dict['tag'], expected_node_dict['tag'])
# Check attributes.
node_dict_attrib = {k: '___ignore___' if expected_node_dict['attrib'].get(k) == '___ignore___' else v
for k, v in node_dict['attrib'].items()}
expected_node_dict_attrib = {k: v for k, v in expected_node_dict['attrib'].items() if v != '___remove___'}
self.assertDictEqual(
node_dict_attrib,
expected_node_dict_attrib,
"Element attributes are different for node %s" % node_dict['tag'],
)
# Check text.
if expected_node_dict['text'] != '___ignore___':
self.assertEqual(
node_dict['text'],
expected_node_dict['text'],
"Element text are different for node %s" % node_dict['tag'],
)
# Check children.
self.assertEqual(
[child['tag'] for child in node_dict['children']],
[child['tag'] for child in expected_node_dict['children']],
"Number of children elements for node %s is different." % node_dict['tag'],
)
for child_node_dict, expected_child_node_dict in zip(node_dict['children'], expected_node_dict['children']):
assertNodeDictEqual(child_node_dict, expected_child_node_dict)
assertNodeDictEqual(
_turn_node_as_dict_hierarchy(xml_tree),
_turn_node_as_dict_hierarchy(expected_xml_tree),
)
def with_applied_xpath(self, xml_tree, xpath):
''' Applies the xpath to the xml_tree passed as parameter.
:param xml_tree: An instance of etree.
:param xpath: The xpath to apply as a string.
:return: The resulting etree after applying the xpaths.
'''
diff_xml_tree = etree.fromstring('<data>%s</data>' % xpath)
return self.env['ir.ui.view'].apply_inheritance_specs(xml_tree, diff_xml_tree)
def get_xml_tree_from_attachment(self, attachment):
''' Extract an instance of etree from an ir.attachment.
:param attachment: An ir.attachment.
:return: An instance of etree.
'''
return etree.fromstring(base64.b64decode(attachment.with_context(bin_size=False).datas))
def get_xml_tree_from_string(self, xml_tree_str):
''' Convert the string passed as parameter to an instance of etree.
:param xml_tree_str: A string representing an xml.
:return: An instance of etree.
'''
return etree.fromstring(xml_tree_str)
+94 -2
View File
@@ -4,7 +4,8 @@ from odoo import fields
from odoo.tests.common import SavepointCase, HttpSavepointCase, tagged, Form
import time
import base64
from lxml import etree
@tagged('post_install', '-at_install')
class AccountTestInvoicingCommon(SavepointCase):
@@ -45,7 +46,7 @@ class AccountTestInvoicingCommon(SavepointCase):
cls.cr = cls.env.cr
cls.company_data_2 = cls.setup_company_data('company_2_data', chart_template=chart_template)
cls.company_data = cls.setup_company_data('company_1_data', chart_template=chart_template)
cls.company_data = cls.setup_company_data('company_1_data', chart_template=chart_template)
user.write({
'company_ids': [(6, 0, (cls.company_data['company'] + cls.company_data_2['company']).ids)],
@@ -375,6 +376,97 @@ class AccountTestInvoicingCommon(SavepointCase):
self.assertRecordValues(sort_lines(move.invoice_line_ids.sorted()), expected_lines_values[:len(move.invoice_line_ids)])
self.assertRecordValues(move, [expected_move_values])
####################################################
# Xml Comparison
####################################################
def _turn_node_as_dict_hierarchy(self, node):
''' Turn the node as a python dictionary to be compared later with another one.
Allow to ignore the management of namespaces.
:param node: A node inside an xml tree.
:return: A python dictionary.
'''
tag_split = node.tag.split('}')
tag_wo_ns = tag_split[-1]
attrib_wo_ns = {k: v for k, v in node.attrib.items() if '}' not in k}
return {
'tag': tag_wo_ns,
'namespace': None if len(tag_split) < 2 else tag_split[0],
'text': (node.text or '').strip(),
'attrib': attrib_wo_ns,
'children': [self._turn_node_as_dict_hierarchy(child_node) for child_node in node.getchildren()],
}
def assertXmlTreeEqual(self, xml_tree, expected_xml_tree):
''' Compare two lxml.etree.
:param xml_tree: The current tree.
:param expected_xml_tree: The expected tree.
'''
def assertNodeDictEqual(node_dict, expected_node_dict):
''' Compare nodes created by the `_turn_node_as_dict_hierarchy` method.
:param node_dict: The node to compare with.
:param expected_node_dict: The expected node.
'''
# Check tag.
self.assertEqual(node_dict['tag'], expected_node_dict['tag'])
# Check attributes.
node_dict_attrib = {k: '___ignore___' if expected_node_dict['attrib'].get(k) == '___ignore___' else v
for k, v in node_dict['attrib'].items()}
expected_node_dict_attrib = {k: v for k, v in expected_node_dict['attrib'].items() if v != '___remove___'}
self.assertDictEqual(
node_dict_attrib,
expected_node_dict_attrib,
"Element attributes are different for node %s" % node_dict['tag'],
)
# Check text.
if expected_node_dict['text'] != '___ignore___':
self.assertEqual(
node_dict['text'],
expected_node_dict['text'],
"Element text are different for node %s" % node_dict['tag'],
)
# Check children.
self.assertEqual(
[child['tag'] for child in node_dict['children']],
[child['tag'] for child in expected_node_dict['children']],
"Number of children elements for node %s is different." % node_dict['tag'],
)
for child_node_dict, expected_child_node_dict in zip(node_dict['children'], expected_node_dict['children']):
assertNodeDictEqual(child_node_dict, expected_child_node_dict)
assertNodeDictEqual(
self._turn_node_as_dict_hierarchy(xml_tree),
self._turn_node_as_dict_hierarchy(expected_xml_tree),
)
def with_applied_xpath(self, xml_tree, xpath):
''' Applies the xpath to the xml_tree passed as parameter.
:param xml_tree: An instance of etree.
:param xpath: The xpath to apply as a string.
:return: The resulting etree after applying the xpaths.
'''
diff_xml_tree = etree.fromstring('<data>%s</data>' % xpath)
return self.env['ir.ui.view'].apply_inheritance_specs(xml_tree, diff_xml_tree)
def get_xml_tree_from_attachment(self, attachment):
''' Extract an instance of etree from an ir.attachment.
:param attachment: An ir.attachment.
:return: An instance of etree.
'''
return etree.fromstring(base64.b64decode(attachment.with_context(bin_size=False).datas))
def get_xml_tree_from_string(self, xml_tree_str):
''' Convert the string passed as parameter to an instance of etree.
:param xml_tree_str: A string representing an xml.
:return: An instance of etree.
'''
return etree.fromstring(xml_tree_str)
@tagged('post_install', '-at_install')
class AccountTestInvoicingHttpCommon(AccountTestInvoicingCommon, HttpSavepointCase):
@@ -196,6 +196,7 @@
<field name="is_reconciled" invisible="1"/>
<field name="currency_id" invisible="1"/>
<field name="partner_bank_id" invisible="1"/>
<field name="country_code" invisible="1"/>
<!-- Visible fields -->
<field name="sequence" widget="handle"/>
@@ -36,6 +36,7 @@
</group>
<group>
<field name="company_id" options="{'no_create': True}" groups="base.group_multi_company"/>
<field name="country_code" invisible="1"/>
</group>
</group>
<notebook>
+3 -2
View File
@@ -523,7 +523,7 @@
<button name="button_cancel" string="Cancel Entry" type="object" groups="account.group_account_invoice"
attrs="{'invisible' : ['|', ('id', '=', False), ('state', '!=', 'draft')]}"/>
<button name="button_draft" string="Reset to Draft" type="object" groups="account.group_account_invoice"
attrs="{'invisible' : ['|', ('restrict_mode_hash_table', '=', True), ('state', 'not in', ('posted', 'cancel'))]}"/>
attrs="{'invisible' : [('show_reset_to_draft_button', '=', False)]}"/>
<field name="state" widget="statusbar" statusbar_visible="draft,posted"/>
</header>
<!-- Invoice outstanding credits -->
@@ -584,6 +584,7 @@
<field name="commercial_partner_id" invisible="1"/>
<field name="bank_partner_id" invisible="1"/>
<field name="display_qr_code" invisible="1"/>
<field name="show_reset_to_draft_button" invisible="1"/>
<field name="invoice_has_outstanding" invisible="1"/>
<field name="is_move_sent" invisible="1"/>
@@ -671,7 +672,7 @@
<label for="journal_id"
groups="account.group_account_readonly"
invisible="context.get('default_journal_id') and context.get('move_type', 'entry') != 'entry'"/>
<div class="d-flex" invisible="context.get('default_journal_id') and context.get('move_type', 'entry') != 'entry'">
<div name="journal_div" class="d-flex" invisible="context.get('default_journal_id') and context.get('move_type', 'entry') != 'entry'">
<field name="journal_id"
groups="account.group_account_readonly"
options="{'no_create': True}"
@@ -159,6 +159,7 @@
<field name="hide_payment_method" invisible="1"/>
<field name="available_payment_method_ids" invisible="1"/>
<field name="suitable_journal_ids" invisible="1"/>
<field name="country_code" invisible="1"/>
<div class="oe_button_box" name="button_box">
<!-- Invoice stat button -->
@@ -134,6 +134,7 @@
<page string="Definition" name="definition">
<div attrs="{'invisible': [('amount_type', '=', 'group')]}">
<field name="tax_fiscal_country_id" invisible="1"/>
<field name="country_code" invisible="1"/>
<group string="Repartition for Invoices">
<field name="invoice_repartition_line_ids" nolabel="1" context="{'default_company_id': company_id}"/>
</group>
@@ -103,6 +103,7 @@ class AccountPaymentRegister(models.TransientModel):
require_partner_bank_account = fields.Boolean(
compute='_compute_show_require_partner_bank',
help="Technical field used to know whether the field `partner_bank_id` needs to be required or not in the payments form views")
country_code = fields.Char(related='company_id.country_id.code', readonly=True)
# -------------------------------------------------------------------------
# HELPERS
@@ -18,6 +18,7 @@
<field name="source_currency_id" invisible="1" force_save="1"/>
<field name="company_id" invisible="1" force_save="1"/>
<field name="partner_id" invisible="1" force_save="1"/>
<field name="country_code" invisible="1" force_save="1"/>
<field name="show_partner_bank_account" invisible="1"/>
<field name="require_partner_bank_account" invisible="1"/>
+5 -1
View File
@@ -16,7 +16,11 @@ governements, etc.)
'data': [
'security/ir.model.access.csv',
'wizard/account_invoice_send_views.xml',
'views/account_journal_views.xml'
'views/account_edi_document_views.xml',
'views/account_move_views.xml',
'views/account_payment_views.xml',
'views/account_journal_views.xml',
'data/cron.xml'
],
'installable': True,
'application': False,
+11
View File
@@ -0,0 +1,11 @@
<odoo>
<record id="ir_cron_edi_network" model="ir.cron">
<field name="name">EDI : Perform web services operations</field>
<field name="model_id" ref="model_account_edi_document"/>
<field name="state">code</field>
<field name="code">model.search([('state', 'in', ('to_send', 'to_cancel'))])._process_documents_web_services(job_count=20)</field>
<field name="interval_number">1</field>
<field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
</record>
</odoo>
+2 -1
View File
@@ -3,6 +3,7 @@
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 ir_attachment
from . import mail_template
@@ -0,0 +1,177 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields, api, _
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')
edi_format_id = fields.Many2one('account.edi.format')
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()
# == 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 _prepare_jobs(self):
"""Creates a list of jobs to be performed by '_process_jobs' 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
"""
to_process = []
batches = {}
for edi_doc in self.filtered(lambda d: d.state in ('to_send', 'to_cancel')):
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
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
@api.model
def _process_jobs(self, to_process):
"""Post or cancel move_id (invoice or payment) by calling the related methods on edi_format_id.
Invoices are processed before payments.
"""
def _postprocess_post_edi_results(documents, edi_result):
for document in documents:
move = document.move_id
move_result = edi_result.get(move, {})
if move_result.get('attachment'):
document.write({
'attachment_id': move_result['attachment'].id,
'state': 'sent',
'error': False,
})
else:
document.error = move_result.get('error', _("Error when processing the journal entry."))
def _postprocess_cancel_edi_results(documents, edi_result):
invoice_ids_to_cancel = set() # Avoid duplicates
for document in documents:
move = document.move_id
move_result = edi_result.get(move, {})
if move_result.get('success'):
document.write({
'state': 'cancelled',
'error': False,
'attachment_id': False,
})
# The user requested a cancellation of the EDI and it has been approved. Then, the invoice
# can be safely cancelled.
if move.is_invoice(include_receipts=True) and move.state == 'posted':
invoice_ids_to_cancel.add(move.id)
else:
document.error = move_result.get('error') or _("Error when cancelling the journal entry.")
if invoice_ids_to_cancel:
invoices = self.env['account.move'].browse(list(invoice_ids_to_cancel))
invoices.button_draft()
invoices.button_cancel()
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:
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(self.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
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)
def _process_documents_web_services(self, job_count=None):
""" 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)])
+120 -50
View File
@@ -19,51 +19,140 @@ class AccountEdiFormat(models.Model):
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')
]
####################################################
# Low-level methods
####################################################
@api.model_create_multi
def create(self, vals_list):
edi_formats = super().create(vals_list)
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
return edi_formats
####################################################
# Export method to override based on EDI Format
####################################################
def _export_invoice_to_attachment(self, invoice):
""" Create the file content representing the invoice.
def _is_required_for_invoice(self, invoice):
""" Indicate if this EDI must be generated for the invoice passed as parameter.
: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
: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 _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).
def _needs_web_services(self):
""" Indicate if the EDI must be generated asynchronously through to some web services.
: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
: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 self._export_invoice_to_attachment(invoice)
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 _support_batching(self):
""" 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 _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.
"""
# 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.
"""
# 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.
"""
# 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.
"""
# 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
@@ -129,12 +218,10 @@ class AccountEdiFormat(models.Model):
"""
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)
attachment = invoice.edi_document_ids.filtered(lambda d: d.edi_format_id == edi_format).attachment_id
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})
if attachments:
# Add the attachments to the pdf file
@@ -151,23 +238,6 @@ class AccountEdiFormat(models.Model):
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)
####################################################
@@ -268,7 +338,7 @@ class AccountEdiFormat(models.Model):
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()
file_data['pdf_reader'].streamS.close()
if res:
if 'extract_state' in res:
# Bypass the OCR to prevent overwriting data when an EDI was succesfully imported.
+44 -3
View File
@@ -1,7 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields
from odoo import api, models, fields, _
from odoo.exceptions import UserError
class AccountJournal(models.Model):
@@ -10,12 +11,52 @@ class AccountJournal(models.Model):
edi_format_ids = fields.Many2many(comodel_name='account.edi.format',
string='Electronic invoicing',
help='Send XML/EDI invoices',
domain="[('hide_on_journal', '=', 'import_export')]")
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))
def _create_invoice_from_single_attachment(self, attachment):
# OVERRIDE
invoice = self.env['account.edi.format'].search([])._create_invoice_from_attachment(attachment)
if invoice:
# with_context: we don't want to import the attachment since the invoice was just created from it.
invoice.with_context(no_new_invoice=True).message_post(attachment_ids=[attachment.id])
invoice.with_context(no_new_invoice=True).message_post(attachment_ids=attachment.ids)
return invoice
return super()._create_invoice_from_single_attachment(attachment)
+257 -16
View File
@@ -1,34 +1,214 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models, fields
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class AccountMove(models.Model):
_inherit = 'account.move'
edi_document_ids = fields.One2many('ir.attachment', 'res_id', domain=[('res_model', '=', 'account.move'), ('edi_format_id', '!=', False)])
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.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,
})
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,
})
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
# Generate the electronic documents for the move.
existing_attachments = self.env['ir.attachment'].search([
('res_model', '=', 'account.move'),
('res_id', 'in', self.ids),
('edi_format_id', 'in', self.journal_id.edi_format_ids.ids)])
existing_attachments.unlink()
posted = super()._post(soft)
for move in posted:
move.journal_id.edi_format_ids._create_ir_attachments(move)
# Set the electronic document to be posted and post immediately for synchronous formats.
res = super()._post(soft=soft)
return posted
edi_document_vals_list = []
for move in self:
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:
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)
self.edi_document_ids._process_documents_no_web_services()
return res
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})
self.edi_document_ids.filtered(lambda doc: not doc.attachment_id).write({'state': 'cancelled', 'error': False})
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})
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})
####################################################
# Import Electronic Document
####################################################
@api.returns('mail.message', lambda value: value.id)
def message_post(self, **kwargs):
# OVERRIDE
# When posting a message, analyse the attachment to check if it is an EDI document and update the invoice
# with the data.
res = super(AccountMove, self).message_post(**kwargs)
# with the imported data.
res = super().message_post(**kwargs)
if len(self) != 1 or self.env.context.get('no_new_invoice') or not self.is_invoice(include_receipts=True):
return res
@@ -48,8 +228,69 @@ class AccountMove(models.Model):
author_id=odoobot.id)
return res
edi_formats = self.env['account.edi.format'].search([])
for attachment in attachments:
invoice = self.env['account.edi.format'].search([])._update_invoice_from_attachment(attachment, self)
break
invoice = edi_formats._update_invoice_from_attachment(attachment, self)
if invoice:
break
return res
####################################################
# Business operations
####################################################
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()
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
@@ -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()
@@ -1,10 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields
class IrAttachment(models.Model):
_inherit = 'ir.attachment'
edi_format_id = fields.Many2one('account.edi.format')
+12 -10
View File
@@ -17,16 +17,18 @@ class MailTemplate(models.Model):
if self.model != 'account.move':
return res
existing_attachments = self.env['ir.attachment'].search([
('res_model', '=', 'account.move'),
('res_id', 'in', res_ids),
('edi_format_id', '!=', False)])
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:
for record in self.env[self.model].browse(res_ids):
available_formats = existing_attachments.filtered(lambda a: a.res_id == record.id)
missing_formats = record.journal_id.edi_format_ids.filtered(lambda f: f._origin.id not in available_formats.edi_format_id.ids)
new_attachments = missing_formats._create_ir_attachments(record)
available_formats |= new_attachments
(res[record.id] if multi_mode else res).setdefault('attachments', []).extend([(a.name, a.datas) for a in available_formats])
# 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
record_data.setdefault('attachments', [])
record_data['attachments'].append((attachment.name, attachment.datas))
return res
@@ -1,2 +1,5 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_account_edi_format_readonly,account.edi.format,model_account_edi_format,,1,0,0,0
access_account_edi_format_group_invoice,account.edi.format,model_account_edi_format,account.group_account_invoice,1,1,1,1
access_account_edi_document_readonly,account.edi.document,model_account_edi_document,,1,0,0,0
access_account_edi_document_group_invoice,account.edi.document,model_account_edi_document,account.group_account_invoice,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_account_edi_format_readonly account.edi.format model_account_edi_format 1 0 0 0
3 access_account_edi_format_group_invoice account.edi.format model_account_edi_format account.group_account_invoice 1 1 1 1
4 access_account_edi_document_readonly account.edi.document model_account_edi_document 1 0 0 0
5 access_account_edi_document_group_invoice account.edi.document model_account_edi_document account.group_account_invoice 1 1 1 1
+5
View File
@@ -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
+82
View File
@@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.modules.module import get_module_resource
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from contextlib import contextmanager
from unittest.mock import patch
from unittest import mock
import base64
class AccountEdiTestCommon(AccountTestInvoicingCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None, edi_format_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
# ==== EDI ====
if edi_format_ref:
cls.edi_format = cls.env.ref(edi_format_ref)
else:
cls.edi_format = cls.env['account.edi.format'].sudo().create({
'name': 'Test EDI format',
'code': 'test_edi',
})
cls.journal = cls.company_data['default_journal_sale']
cls.journal.edi_format_ids = [(6, 0, cls.edi_format.ids)]
####################################################
# EDI helpers
####################################################
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()
def update_invoice_from_file(self, module_name, subfolder, filename, invoice):
file_path = get_module_resource(module_name, subfolder, filename)
file = open(file_path, 'rb').read()
attachment = self.env['ir.attachment'].create({
'name': filename,
'datas': base64.encodebytes(file),
'res_id': invoice.id,
'res_model': 'account.move',
})
invoice.message_post(attachment_ids=[attachment.id])
def create_invoice_from_file(self, module_name, subfolder, filename):
file_path = get_module_resource(module_name, 'test_file', filename)
file = open(file_path, 'rb').read()
attachment = self.env['ir.attachment'].create({
'name': filename,
'datas': base64.encodebytes(file),
'res_model': 'account.move',
})
journal_id = self.company_data['default_journal_sale']
invoice = journal_id.with_context(default_move_type='in_invoice')._create_invoice_from_single_attachment(attachment)
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
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)
if applied_xpath:
expected_etree = self.with_applied_xpath(expected_etree, applied_xpath)
self.assertXmlTreeEqual(current_etree, expected_etree)
def _process_documents_web_services(self, moves):
moves.edi_document_ids.with_context(edi_test_mode=True)._process_documents_web_services()
attachments = moves.edi_document_ids.attachment_id
data_str_list = []
for attachment in attachments.with_context(bin_size=False):
data_str_list.append(base64.decodebytes(attachment.datas))
return data_str_list
+46
View File
@@ -0,0 +1,46 @@
# -*- 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 unittest.mock import patch
class TestAccountEdi(AccountEdiTestCommon):
def test_export_edi(self):
invoice = self.init_invoice('out_invoice')
self.assertEqual(len(invoice.edi_document_ids), 0)
invoice.action_post()
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')
to_process = edi_docs._prepare_jobs()
self.assertEqual(len(to_process), 2)
with patch('odoo.addons.account_edi.models.account_edi_format.AccountEdiFormat._support_batching', return_value=True):
to_process = edi_docs._prepare_jobs()
self.assertEqual(len(to_process), 1)
other_edi = self.env['account.edi.format'].sudo().create({
'name': 'Batchable EDI format 2',
'code': 'test_batch_edi_2',
})
edi_docs |= create_edi_document(other_edi, 'to_send')
edi_docs |= 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()
self.assertEqual(len(to_process), 2)
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="view_tree_account_edi_document" model="ir.ui.view">
<field name="name">Account.edi.document.tree</field>
<field name="model">account.edi.document</field>
<field name="arch" type="xml">
<tree create="false" delete="false" edit="false">
<field name="edi_format_name" />
<field name="error" />
</tree>
</field>
</record>
</data>
</odoo>
@@ -6,9 +6,10 @@
<field name="model">account.journal</field>
<field name="inherit_id" ref="account.view_account_journal_form" />
<field name="arch" type="xml">
<xpath expr="//page[@name='advanced_settings']//group[@name='group_alias_edit']" position="after">
<group string="Electronic Data Interchange" attrs="{'invisible': [('type', '!=', 'sale')]}">
<field name="edi_format_ids" widget="many2many_tags"/>
<xpath expr="//group[@name='group_alias_edit']" position="after">
<group string="Electronic Data Interchange" attrs="{'invisible': [('compatible_edi_ids', '=', [])]}">
<field name="compatible_edi_ids" invisible="1" />
<field name="edi_format_ids" widget="many2many_checkboxes"/>
</group>
</xpath>
</field>
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="account_edi.action_open_edi_documents" model="ir.actions.act_window">
<field name="name">Electronic invoicing</field>
<field name="res_model">account.edi.document</field>
<field name="view_mode">tree</field>
<field name="domain">[('move_id', '=', active_id), ('error', '!=', False)]</field>
</record>
<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.view_move_form" />
<field name="arch" type="xml">
<xpath expr="//button[@name='button_cancel']" position="after">
<field name="edi_show_cancel_button" invisible="1"/>
<button name="button_cancel_posted_moves"
string="Request EDI Cancellation"
type="object"
groups="account.group_account_invoice"
attrs="{'invisible' : [('edi_show_cancel_button', '=', False)]}"/>
</xpath>
<xpath expr="//header" position="after">
<div class="alert alert-info" role="alert" style="margin-bottom:0px;"
attrs="{'invisible': [('edi_web_services_to_process', '=', '')]}">
<div>The invoice will be sent asynchronously to :
<field name="edi_web_services_to_process" class="oe_inline"/>
</div>
<button name="action_process_edi_web_services" type="object" class="oe_link" string="Send now" />
</div>
<div class="alert alert-warning" role="alert" style="margin-bottom:0px;"
attrs="{'invisible': [('edi_error_count', '=', 0)]}">
<field name="edi_error_count"/>
Electronic invoicing error(s)
<button name="%(account_edi.action_open_edi_documents)d" type="action" class="oe_link oe_inline" string="⇒ See errors" />
</div>
</xpath>
<xpath expr="//div[@name='journal_div']" position="after">
<field name="edi_document_ids" invisible="1" />
<field name="edi_state" invisible="1" />
<field name="edi_state" attrs="{'invisible': ['|', ('edi_document_ids', '=', []), ('state', '=', 'draft')]}"/>
</xpath>
</field>
</record>
</data>
</odoo>
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="account_edi.action_open_payment_edi_documents" model="ir.actions.act_window">
<field name="name">Electronic invoicing</field>
<field name="res_model">account.edi.document</field>
<field name="view_mode">tree</field>
<field name="domain">[('move_id.payment_id', '=', active_id), ('error', '!=', False)]</field>
</record>
<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.view_account_payment_form" />
<field name="arch" type="xml">
<xpath expr="//header" position="after">
<div class="alert alert-info" role="alert" style="margin-bottom:0px;"
attrs="{'invisible': ['|', ('edi_web_services_to_process', '=', ''), ('state', '=', 'draft')]}">
<div>The payment will be sent asynchronously to :
<field name="edi_web_services_to_process" class="oe_inline"/>
</div>
<button name="action_process_edi_web_services" type="object" class="oe_link" string="Send now"/>
</div>
<div class="alert alert-warning" role="alert" style="margin-bottom:0px;"
attrs="{'invisible': [('edi_error_count', '=', 0)]}">
<field name="edi_error_count"/>
Electronic invoicing error(s)
<button name="%(account_edi.action_open_payment_edi_documents)d" type="action" class="oe_link oe_inline" string="⇒ See errors" />
</div>
</xpath>
<xpath expr="//field[@name='journal_id']" position="after">
<field name="edi_document_ids" invisible="1" />
<field name="edi_state" invisible="1" />
<field name="edi_state" attrs="{'invisible': ['|', ('edi_document_ids', '=', []), ('state', '=', 'draft')]}"/>
</xpath>
</field>
</record>
</data>
</odoo>
@@ -8,6 +8,7 @@ from odoo.exceptions import UserError
from datetime import datetime
from lxml import etree
from PyPDF2 import PdfFileReader
import base64
import io
@@ -22,10 +23,29 @@ DEFAULT_FACTURX_DATE_FORMAT = '%Y%m%d'
class AccountEdiFormat(models.Model):
_inherit = 'account.edi.format'
def _export_invoice_to_attachment(self, invoice):
def _is_compatible_with_journal(self, journal):
self.ensure_one()
res = super()._is_compatible_with_journal(journal)
if self.code != 'facturx_1_0_05':
return res
return journal.type == 'sale'
def _post_invoice_edi(self, invoices, test_mode=False):
self.ensure_one()
if self.code != 'facturx_1_0_05':
return super()._export_invoice_to_attachment(invoice)
return super()._post_invoice_edi(invoices, test_mode=test_mode)
res = {}
for invoice in invoices:
attachment = self._export_facturx(invoice)
res[invoice] = {'attachment': attachment}
return res
def _is_embedding_to_invoice_pdf_needed(self):
# OVERRIDE
self.ensure_one()
return True if self.code == 'facturx_1_0_05' else super()._is_embedding_to_invoice_pdf_needed()
def _export_facturx(self, invoice):
def format_date(dt):
# Format the date in the Factur-x standard.
@@ -36,6 +56,7 @@ class AccountEdiFormat(models.Model):
# Format the monetary values to avoid trailing decimals (e.g. 90.85000000000001).
return float_repr(number, currency.decimal_places)
self.ensure_one()
# Create file content.
template_values = {
'record': invoice,
@@ -88,12 +109,13 @@ class AccountEdiFormat(models.Model):
xml_content = b"<?xml version='1.0' encoding='UTF-8'?>"
xml_content += self.env.ref('account_edi_facturx.account_invoice_facturx_export')._render(template_values)
xml_name = '%s_facturx.xml' % (invoice.name.replace('/', '_'))
return {'name': xml_name,
'datas': xml_content,
'res_model': 'account.move',
'res_id': invoice._origin.id,
'mimetype': 'application/xml'
}
return self.env['ir.attachment'].create({
'name': xml_name,
'datas': base64.encodebytes(xml_content),
'res_model': 'account.move',
'res_id': invoice._origin.id,
'mimetype': 'application/xml'
})
def _is_facturx(self, filename, tree):
return self.code == 'facturx_1_0_05' and tree.tag == '{urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100}CrossIndustryInvoice'
@@ -0,0 +1,148 @@
<?xml version='1.0' encoding='UTF-8'?>
<rsm:CrossIndustryInvoice xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100"
xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100">
<rsm:ExchangedDocumentContext>
<ram:GuidelineSpecifiedDocumentContextParameter>
<ram:ID>urn:cen.eu:en16931:2017</ram:ID>
</ram:GuidelineSpecifiedDocumentContextParameter>
</rsm:ExchangedDocumentContext>
<rsm:ExchangedDocument>
<ram:TypeCode>380</ram:TypeCode>
<ram:IssueDateTime>
<udt:DateTimeString format="102">20200501</udt:DateTimeString>
</ram:IssueDateTime>
</rsm:ExchangedDocument>
<rsm:SupplyChainTradeTransaction>
<ram:IncludedSupplyChainTradeLineItem>
<ram:AssociatedDocumentLineDocument>
<ram:LineID>1</ram:LineID>
</ram:AssociatedDocumentLineDocument>
<ram:SpecifiedTradeProduct>
<ram:SellerAssignedID>FURN_6741</ram:SellerAssignedID>
<ram:Name>[FURN_6741] Large Meeting Table
Conference room table</ram:Name>
</ram:SpecifiedTradeProduct>
<ram:SpecifiedLineTradeAgreement>
<ram:GrossPriceProductTradePrice>
<ram:ChargeAmount currencyID="USD">3210.00</ram:ChargeAmount>
</ram:GrossPriceProductTradePrice>
</ram:SpecifiedLineTradeAgreement>
<ram:SpecifiedLineTradeDelivery>
<ram:BilledQuantity>5.0</ram:BilledQuantity>
</ram:SpecifiedLineTradeDelivery>
<ram:SpecifiedLineTradeSettlement>
<ram:SpecifiedTradeSettlementLineMonetarySummation>
<ram:LineTotalAmount currencyID="USD">3210.00</ram:LineTotalAmount>
</ram:SpecifiedTradeSettlementLineMonetarySummation>
</ram:SpecifiedLineTradeSettlement>
</ram:IncludedSupplyChainTradeLineItem>
<ram:IncludedSupplyChainTradeLineItem>
<ram:AssociatedDocumentLineDocument>
<ram:LineID>2</ram:LineID>
</ram:AssociatedDocumentLineDocument>
<ram:SpecifiedTradeProduct>
<ram:SellerAssignedID>FURN_8220</ram:SellerAssignedID>
<ram:Name>[FURN_8220] Four Person Desk
Four person modern office workstation</ram:Name>
</ram:SpecifiedTradeProduct>
<ram:SpecifiedLineTradeAgreement>
<ram:GrossPriceProductTradePrice>
<ram:ChargeAmount currencyID="USD">1400.00</ram:ChargeAmount>
</ram:GrossPriceProductTradePrice>
</ram:SpecifiedLineTradeAgreement>
<ram:SpecifiedLineTradeDelivery>
<ram:BilledQuantity>5.0</ram:BilledQuantity>
</ram:SpecifiedLineTradeDelivery>
<ram:SpecifiedLineTradeSettlement>
<ram:SpecifiedTradeSettlementLineMonetarySummation>
<ram:LineTotalAmount currencyID="USD">1400.00</ram:LineTotalAmount>
</ram:SpecifiedTradeSettlementLineMonetarySummation>
</ram:SpecifiedLineTradeSettlement>
</ram:IncludedSupplyChainTradeLineItem>
<ram:ApplicableHeaderTradeAgreement>
<ram:SellerTradeParty>
<ram:Name>YourCompany</ram:Name>
<ram:DefinedTradeContact>
<ram:PersonName>YourCompany</ram:PersonName>
<ram:TelephoneUniversalCommunication>
<ram:CompleteNumber>+1 (650) 691-3277 </ram:CompleteNumber>
</ram:TelephoneUniversalCommunication>
<ram:EmailURIUniversalCommunication>
<ram:URIID schemeID="SMTP">info@yourcompany.example.com</ram:URIID>
</ram:EmailURIUniversalCommunication>
</ram:DefinedTradeContact>
<ram:PostalTradeAddress>
<ram:PostcodeCode>94134</ram:PostcodeCode>
<ram:LineOne>250 Executive Park Blvd, Suite 3400</ram:LineOne>
<ram:CityName>San Francisco</ram:CityName>
<ram:CountryID>US</ram:CountryID>
</ram:PostalTradeAddress>
</ram:SellerTradeParty>
<ram:BuyerTradeParty>
<ram:Name>Azure Interior</ram:Name>
<ram:DefinedTradeContact>
<ram:PersonName>Azure Interior</ram:PersonName>
<ram:TelephoneUniversalCommunication>
<ram:CompleteNumber>(870)-931-0505</ram:CompleteNumber>
</ram:TelephoneUniversalCommunication>
<ram:EmailURIUniversalCommunication>
<ram:URIID schemeID="SMTP">azure.Interior24@example.com</ram:URIID>
</ram:EmailURIUniversalCommunication>
</ram:DefinedTradeContact>
<ram:PostalTradeAddress>
<ram:PostcodeCode>94538</ram:PostcodeCode>
<ram:LineOne>4557 De Silva St</ram:LineOne>
<ram:CityName>Fremont</ram:CityName>
<ram:CountryID>US</ram:CountryID>
</ram:PostalTradeAddress>
</ram:BuyerTradeParty>
<ram:BuyerOrderReferencedDocument>
<ram:IssuerAssignedID>INV/2020/05/0001: INV/2020/05/0001</ram:IssuerAssignedID>
</ram:BuyerOrderReferencedDocument>
</ram:ApplicableHeaderTradeAgreement>
<ram:ApplicableHeaderTradeDelivery>
</ram:ApplicableHeaderTradeDelivery>
<ram:ApplicableHeaderTradeSettlement>
<ram:SpecifiedTradePaymentTerms>
<ram:Description>End of Following Month</ram:Description>
<ram:DueDateDateTime>
<udt:DateTimeString>20200630</udt:DateTimeString>
</ram:DueDateDateTime>
</ram:SpecifiedTradePaymentTerms>
<ram:SpecifiedTradeSettlementHeaderMonetarySummation>
<ram:LineTotalAmount currencyID="USD">4610.00</ram:LineTotalAmount>
<ram:TaxBasisTotalAmount currencyID="USD">0.00</ram:TaxBasisTotalAmount>
<ram:TaxTotalAmount currencyID="USD">0.00</ram:TaxTotalAmount>
<ram:GrandTotalAmount currencyID="USD">4610.00</ram:GrandTotalAmount>
<ram:TotalPrepaidAmount currencyID="USD">0.00</ram:TotalPrepaidAmount>
<ram:DuePayableAmount currencyID="USD">4610.00</ram:DuePayableAmount>
</ram:SpecifiedTradeSettlementHeaderMonetarySummation>
</ram:ApplicableHeaderTradeSettlement>
</rsm:SupplyChainTradeTransaction>
</rsm:CrossIndustryInvoice>
@@ -1,16 +1,15 @@
# -*- coding: utf-8 -*-
from freezegun import freeze_time
from odoo.addons.account.tests.account_test_xml import AccountTestEdiCommon
from odoo.addons.account_edi.tests.common import AccountEdiTestCommon
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestAccountEdiFacturx(AccountTestEdiCommon):
class TestAccountEdiFacturx(AccountEdiTestCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
def setUpClass(cls, chart_template_ref=None, edi_format_ref='account_edi_facturx.edi_facturx_1_0_05'):
super().setUpClass(chart_template_ref=chart_template_ref, edi_format_ref=edi_format_ref)
# ==== Init ====
@@ -40,12 +39,6 @@ class TestAccountEdiFacturx(AccountTestEdiCommon):
'children_tax_ids': [(6, 0, (cls.tax_10_include + cls.tax_20).ids)],
})
# ==== EDI ====
cls.facturx_edi_format = cls.env.ref('account_edi_facturx.edi_facturx_1_0_05')
cls.journal = cls.company_data['default_journal_sale']
cls.journal.edi_format_ids = [(6, 0, cls.facturx_edi_format.ids)]
# ==== Invoice ====
cls.invoice = cls.env['account.move'].create({
@@ -153,16 +146,13 @@ class TestAccountEdiFacturx(AccountTestEdiCommon):
</CrossIndustryInvoice>
'''
@freeze_time('2017-02-01')
####################################################
# Test export
####################################################
def test_facturx(self):
''' Test the generated Facturx Edi attachment without any modification of the invoice. '''
self.invoice.action_post()
xml_content = self.facturx_edi_format._export_invoice_to_attachment(self.invoice)['datas']
current_etree = self.get_xml_tree_from_string(xml_content)
expected_etree = self.get_xml_tree_from_string(self.expected_invoice_facturx_values)
self.assertXmlTreeEqual(current_etree, expected_etree)
self.assert_generated_file_equal(self.invoice, self.expected_invoice_facturx_values)
@freeze_time('2017-02-01')
def test_facturx_group_of_taxes(self):
@@ -171,56 +161,80 @@ class TestAccountEdiFacturx(AccountTestEdiCommon):
'invoice_line_ids': [(1, self.invoice.invoice_line_ids.id, {'tax_ids': [(6, 0, self.tax_group.ids)]})],
})
self.invoice.action_post()
applied_xpath = '''
<xpath expr="//GrossPriceProductTradePrice/ChargeAmount" position="replace">
<ChargeAmount currencyID="Gol">275.000</ChargeAmount>
</xpath>
<xpath expr="//SpecifiedLineTradeSettlement" position="replace">
<SpecifiedLineTradeSettlement>
<ApplicableTradeTax>
<RateApplicablePercent>10.0</RateApplicablePercent>
</ApplicableTradeTax>
<ApplicableTradeTax>
<RateApplicablePercent>20.0</RateApplicablePercent>
</ApplicableTradeTax>
<SpecifiedTradeSettlementLineMonetarySummation>
<LineTotalAmount currencyID="Gol">1000.000</LineTotalAmount>
</SpecifiedTradeSettlementLineMonetarySummation>
</SpecifiedLineTradeSettlement>
</xpath>
<xpath expr="//ApplicableHeaderTradeSettlement" position="replace">
<ApplicableHeaderTradeSettlement>
<ApplicableTradeTax>
<CalculatedAmount currencyID="Gol">220.000</CalculatedAmount>
<BasisAmount currencyID="Gol">1100.000</BasisAmount>
<RateApplicablePercent>20.0</RateApplicablePercent>
</ApplicableTradeTax>
<ApplicableTradeTax>
<CalculatedAmount currencyID="Gol">100.000</CalculatedAmount>
<BasisAmount currencyID="Gol">1000.000</BasisAmount>
<RateApplicablePercent>10.0</RateApplicablePercent>
</ApplicableTradeTax>
<SpecifiedTradePaymentTerms>
<DueDateDateTime>
<DateTimeString>20170101</DateTimeString>
</DueDateDateTime>
</SpecifiedTradePaymentTerms>
<SpecifiedTradeSettlementHeaderMonetarySummation>
<LineTotalAmount currencyID="Gol">1000.000</LineTotalAmount>
<TaxBasisTotalAmount currencyID="Gol">1000.000</TaxBasisTotalAmount>
<TaxTotalAmount currencyID="Gol">320.000</TaxTotalAmount>
<GrandTotalAmount currencyID="Gol">1320.000</GrandTotalAmount>
<TotalPrepaidAmount currencyID="Gol">0.000</TotalPrepaidAmount>
<DuePayableAmount currencyID="Gol">1320.000</DuePayableAmount>
</SpecifiedTradeSettlementHeaderMonetarySummation>
</ApplicableHeaderTradeSettlement>
</xpath>
'''
xml_content = self.facturx_edi_format._export_invoice_to_attachment(self.invoice)['datas']
current_etree = self.get_xml_tree_from_string(xml_content)
expected_etree = self.with_applied_xpath(
self.get_xml_tree_from_string(self.expected_invoice_facturx_values),
'''
<xpath expr="//GrossPriceProductTradePrice/ChargeAmount" position="replace">
<ChargeAmount currencyID="Gol">275.000</ChargeAmount>
</xpath>
<xpath expr="//SpecifiedLineTradeSettlement" position="replace">
<SpecifiedLineTradeSettlement>
<ApplicableTradeTax>
<RateApplicablePercent>10.0</RateApplicablePercent>
</ApplicableTradeTax>
<ApplicableTradeTax>
<RateApplicablePercent>20.0</RateApplicablePercent>
</ApplicableTradeTax>
<SpecifiedTradeSettlementLineMonetarySummation>
<LineTotalAmount currencyID="Gol">1000.000</LineTotalAmount>
</SpecifiedTradeSettlementLineMonetarySummation>
</SpecifiedLineTradeSettlement>
</xpath>
<xpath expr="//ApplicableHeaderTradeSettlement" position="replace">
<ApplicableHeaderTradeSettlement>
<ApplicableTradeTax>
<CalculatedAmount currencyID="Gol">220.000</CalculatedAmount>
<BasisAmount currencyID="Gol">1100.000</BasisAmount>
<RateApplicablePercent>20.0</RateApplicablePercent>
</ApplicableTradeTax>
<ApplicableTradeTax>
<CalculatedAmount currencyID="Gol">100.000</CalculatedAmount>
<BasisAmount currencyID="Gol">1000.000</BasisAmount>
<RateApplicablePercent>10.0</RateApplicablePercent>
</ApplicableTradeTax>
<SpecifiedTradePaymentTerms>
<DueDateDateTime>
<DateTimeString>20170101</DateTimeString>
</DueDateDateTime>
</SpecifiedTradePaymentTerms>
<SpecifiedTradeSettlementHeaderMonetarySummation>
<LineTotalAmount currencyID="Gol">1000.000</LineTotalAmount>
<TaxBasisTotalAmount currencyID="Gol">1000.000</TaxBasisTotalAmount>
<TaxTotalAmount currencyID="Gol">320.000</TaxTotalAmount>
<GrandTotalAmount currencyID="Gol">1320.000</GrandTotalAmount>
<TotalPrepaidAmount currencyID="Gol">0.000</TotalPrepaidAmount>
<DuePayableAmount currencyID="Gol">1320.000</DuePayableAmount>
</SpecifiedTradeSettlementHeaderMonetarySummation>
</ApplicableHeaderTradeSettlement>
</xpath>
''',
)
self.assertXmlTreeEqual(current_etree, expected_etree)
self.assert_generated_file_equal(self.invoice, self.expected_invoice_facturx_values, applied_xpath)
####################################################
# Test import
####################################################
def test_invoice_edi_pdf(self):
invoice = self.env['account.move'].with_context(default_move_type='in_invoice').create({})
invoice_count = len(self.env['account.move'].search([]))
self.update_invoice_from_file('account_edi_facturx', 'test_file', 'test_facturx.pdf', invoice)
self.assertEqual(len(self.env['account.move'].search([])), invoice_count)
self.assertEqual(invoice.amount_total, 525)
self.create_invoice_from_file('account_edi_facturx', 'test_file', 'test_facturx.pdf')
self.assertEqual(invoice.amount_total, 525)
self.assertEqual(len(self.env['account.move'].search([])), invoice_count + 1)
def test_invoice_edi_xml(self):
invoice = self.env['account.move'].with_context(default_move_type='in_invoice').create({})
invoice_count = len(self.env['account.move'].search([]))
self.update_invoice_from_file('account_edi_facturx', 'test_file', 'test_facturx.xml', invoice)
self.assertEqual(len(self.env['account.move'].search([])), invoice_count)
self.assertEqual(invoice.amount_total, 4610)
self.create_invoice_from_file('account_edi_facturx', 'test_file', 'test_facturx.xml')
self.assertEqual(invoice.amount_total, 4610)
self.assertEqual(len(self.env['account.move'].search([])), invoice_count + 1)
+27 -10
View File
@@ -3,6 +3,8 @@
from odoo import api, models, fields
from odoo.tools import float_repr
import base64
class AccountEdiFormat(models.Model):
_inherit = 'account.edi.format'
@@ -22,25 +24,40 @@ class AccountEdiFormat(models.Model):
return self._import_ubl(tree, invoice)
return super()._update_invoice_from_xml_tree(filename, tree, invoice)
def _export_invoice_to_attachment(self, invoice):
def _is_compatible_with_journal(self, journal):
self.ensure_one()
res = super()._is_compatible_with_journal(journal)
if self.code != 'efff_1':
return res
return journal.type == 'sale' and journal.country_code == 'BE'
def _post_invoice_edi(self, invoices, test_mode=False):
self.ensure_one()
if self.code != 'efff_1':
return super()._export_invoice_to_attachment(invoice)
return super()._post_invoice_edi(invoices, test_mode=test_mode)
res = {}
for invoice in invoices:
attachment = self._export_efff(invoice)
res[invoice] = {'attachment': attachment}
return res
def _export_efff(self, invoice):
self.ensure_one()
# Create file content.
xml_content = b"<?xml version='1.0' encoding='UTF-8'?>"
xml_content += self.env.ref('account_edi_ubl.export_ubl_invoice')._render(invoice._get_ubl_values())
vat = invoice.company_id.partner_id.commercial_partner_id.vat
xml_name = 'efff-%s%s%s.xml' % (vat or '', '-' if vat else '', invoice.name.replace('/', '_')) # official naming convention
return {'name': xml_name,
'datas': xml_content,
'res_model': 'account.move',
'res_id': invoice._origin.id,
'mimetype': 'application/xml'
}
return self.env['ir.attachment'].create({
'name': xml_name,
'datas': base64.encodebytes(xml_content),
'res_model': 'account.move',
'res_id': invoice._origin.id,
'mimetype': 'application/xml'
})
def _export_invoice_to_embed_to_pdf(self, pdf_content, invoice):
def _is_embedding_to_invoice_pdf_needed(self):
self.ensure_one()
if self.code != 'efff_1':
return super()._export_invoice_to_embed_to_pdf(pdf_content, invoice)
return super()._is_embedding_to_invoice_pdf_needed()
return False # ubl must not be embedded to PDF.
+11 -26
View File
@@ -1,35 +1,20 @@
# -*- coding: utf-8 -*-
from odoo import tools, _
from odoo.tests import common
import base64
from odoo.modules.module import get_module_resource
from odoo.addons.account_edi.tests.common import AccountEdiTestCommon
class TestUBL(common.TransactionCase):
def setUp(self):
super(TestUBL, self).setUp()
# Force user Belgium country.
self.env.user.company_id = self.env['res.company'].create({'name': 'MyCompany'})
self.env.user.company_id.country_id = self.env.ref('base.be')
self.env.ref('l10n_be.l10nbe_chart_template').try_loading()
self.partner_id = self.env['res.partner'].create({'name': 'TestUser', 'vat': 'BE0123456789'})
def test_ubl_invoice_import(self):
xml_file_path = get_module_resource('l10n_be_edi', 'test_xml_file', 'efff_test.xml')
xml_file = open(xml_file_path, 'rb').read()
class TestUBL(AccountEdiTestCommon):
@classmethod
def setUpClass(cls, chart_template_ref='l10n_be.l10nbe_chart_template', edi_format_ref='l10n_be_edi.edi_efff_1'):
super().setUpClass(chart_template_ref=chart_template_ref, edi_format_ref=edi_format_ref)
cls.partner_a.vat = 'BE0123456789'
def test_invoice_edi_xml(self):
invoice = self.env['account.move'].with_context(default_move_type='in_invoice').create({})
attachment_id = self.env['ir.attachment'].create({
'name': 'efff_test.xml',
'datas': base64.encodebytes(xml_file),
'res_id': invoice.id,
'res_model': 'account.move',
})
invoice_count = len(self.env['account.move'].search([]))
invoice.message_post(attachment_ids=[attachment_id.id])
self.update_invoice_from_file('l10n_be_edi', 'test_xml_file', 'efff_test.xml', invoice)
self.assertEqual(len(self.env['account.move'].search([])), invoice_count)
self.assertEqual(invoice.amount_total, 666.50)
self.assertEqual(invoice.amount_tax, 115.67)
self.assertEqual(invoice.partner_id, self.partner_id)
self.assertEqual(invoice.partner_id, self.partner_a)
@@ -5,7 +5,6 @@
<record id="edi_fatturaPA" model="account.edi.format">
<field name="name">Fattura PA (IT)</field>
<field name="code">fattura_pa</field>
<field name="hide_on_journal">import</field>
</record>
</data>
@@ -20,6 +20,17 @@ DEFAULT_FACTUR_ITALIAN_DATE_FORMAT = '%Y-%m-%d'
class AccountEdiFormat(models.Model):
_inherit = 'account.edi.format'
def _is_embedding_to_invoice_pdf_needed(self):
# OVERRIDE
self.ensure_one()
return True if self.code == 'fattura_pa' else super()._is_embedding_to_invoice_pdf_needed()
def _is_compatible_with_journal(self, journal):
self.ensure_one()
if self.code != 'fattura_pa':
return super()._is_compatible_with_journal(journal)
return False # edi does not support generic export
def _check_filename_is_fattura_pa(self, filename):
return re.search("([A-Z]{2}[A-Za-z0-9]{2,28}_[A-Za-z0-9]{0,5}.(xml.p7m|xml))", filename)