19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:30:07 +01:00
parent ba20ce7443
commit 768b70e05e
2357 changed files with 1057103 additions and 712486 deletions

View file

@ -1,11 +1,7 @@
# -*- encoding: utf-8 -*-
from . import account_move
from . import account_move_send
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
from . import uom

View file

@ -1,13 +1,10 @@
# -*- 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 psycopg2 import OperationalError
import base64
import logging
from collections import defaultdict
from odoo import models, fields, api, _
from odoo.exceptions import LockError, UserError
_logger = logging.getLogger(__name__)
@ -38,15 +35,12 @@ class AccountEdiDocument(models.Model):
# == Not stored fields ==
name = fields.Char(related='attachment_id.name')
edi_format_name = fields.Char(string='Format Name', related='edi_format_id.name')
edi_content = fields.Binary(compute='_compute_edi_content', compute_sudo=True)
edi_content = fields.Binary(compute='_compute_edi_content')
_sql_constraints = [
(
'unique_edi_document_by_move_by_format',
'UNIQUE(edi_format_id, move_id)',
'Only one edi document by move by format',
),
]
_unique_edi_document_by_move_by_format = models.Constraint(
'UNIQUE(edi_format_id, move_id)',
'Only one edi document by move by format',
)
@api.depends('move_id', 'error', 'state')
def _compute_edi_content(self):
@ -73,7 +67,7 @@ class AccountEdiDocument(models.Model):
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
doc_type invoice and company_id AND the edi_format_id supports batching, they are grouped
into a single job.
:returns: [{
@ -85,9 +79,6 @@ class AccountEdiDocument(models.Model):
to_process = {}
for state, edi_flow in (('to_send', 'post'), ('to_cancel', 'cancel')):
documents = self.filtered(lambda d: d.state == state and d.blocking_level != 'error')
# This is a dict that for each batching key gives the value that should be appended to the batching key,
# in order to enforce a limit of documents per batch.
batching_limit_keys = defaultdict(lambda: 0)
for edi_doc in documents:
edi_format = edi_doc.edi_format_id
move = edi_doc.move_id
@ -100,25 +91,17 @@ class AccountEdiDocument(models.Model):
else:
batching_key.append(move.id)
if move_applicability.get('batching_limit'):
batching_key_without_limit = tuple(batching_key)
batching_key.append(batching_limit_keys[batching_key_without_limit])
batch = to_process.setdefault(tuple(batching_key), {
'documents': self.env['account.edi.document'],
'method_to_call': move_applicability.get(edi_flow),
})
batch['documents'] |= edi_doc
if move_applicability.get('batching_limit') and len(batch['documents']) >= move_applicability['batching_limit']:
batching_limit_keys[batching_key_without_limit] += 1
return list(to_process.values())
@api.model
def _process_job(self, job):
"""Post or cancel move_id (invoice or payment) by calling the related methods on edi_format_id.
Invoices are processed before payments.
"""Post or cancel move_id by calling the related methods on edi_format_id.
:param job: {
'documents': account.edi.document,
@ -141,11 +124,6 @@ class AccountEdiDocument(models.Model):
'error': False,
'blocking_level': False,
})
if move.is_invoice(include_receipts=True):
reconciled_lines = move.line_ids.filtered(lambda line: line.account_id.account_type in ('asset_receivable', 'liability_payable'))
reconciled_amls = reconciled_lines.mapped('matched_debit_ids.debit_move_id') \
| reconciled_lines.mapped('matched_credit_ids.credit_move_id')
reconciled_amls.move_id._update_payments_edi_documents()
else:
document.write({
'error': move_result.get('error', False),
@ -212,10 +190,7 @@ class AccountEdiDocument(models.Model):
documents.move_id.line_ids.flush_recordset() # manual flush for tax details
moves = documents.move_id
if state == 'to_send':
if all(move.is_invoice(include_receipts=True) for move in moves):
with moves._send_only_when_ready():
edi_result = method_to_call(moves)
else:
with moves._send_only_when_ready():
edi_result = method_to_call(moves)
_postprocess_post_edi_results(documents, edi_result)
elif state == 'to_cancel':
@ -244,22 +219,14 @@ class AccountEdiDocument(models.Model):
move_to_lock = documents.move_id
attachments_potential_unlink = documents.sudo().attachment_id.filtered(lambda a: not a.res_model and not a.res_id)
try:
with self.env.cr.savepoint(flush=False):
self._cr.execute('SELECT * FROM account_edi_document WHERE id IN %s FOR UPDATE NOWAIT', [tuple(documents.ids)])
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)])
except OperationalError as e:
if e.pgcode == '55P03':
_logger.debug('Another transaction already locked documents rows. Cannot process documents.')
if not with_commit:
raise UserError(_('This document is being sent by another process already. '))
continue
else:
raise e
documents.lock_for_update()
move_to_lock.lock_for_update()
attachments_potential_unlink.lock_for_update()
except LockError:
_logger.debug('Another transaction already locked documents rows. Cannot process documents.')
if not with_commit:
raise UserError(_('This document is being sent by another process already. ')) from None
continue
self._process_job(job)
if with_commit and len(jobs_to_process) > 1:
self.env.cr.commit()
@ -282,3 +249,33 @@ class AccountEdiDocument(models.Model):
# Mark the CRON to be triggered again asap since there is some remaining jobs to process.
if nb_remaining_jobs > 0:
self.env.ref('account_edi.ir_cron_edi_network')._trigger()
def _filter_edi_attachments_for_mailing(self):
"""
Will either return the information about the attachment of the edi document for adding the attachment in the
mail, or the attachment id to be linked to the 'send & print' wizard.
Can be overridden where e.g. a zip-file needs to be sent with the individual files instead of the entire zip
IMPORTANT:
* If the attachment's id is returned, no new attachment will be created, the existing one on the move is linked
to the wizard (see computed attachment_ids field in mail.compose.message).
* If the attachment's content is returned, a new one is created and linked to the wizard. Thus, when sending
the mail (clicking on 'send & print' in the wizard), a new attachment is added to the move (see
_action_send_mail in mail.compose.message).
:param document: an edi document
:return: dict {
'attachments': tuple with the name and base64 content of the attachment}
'attachment_ids': list containing the id of the attachment
}
"""
self.ensure_one()
attachment_sudo = self.sudo().attachment_id
if not attachment_sudo:
return {}
if not (attachment_sudo.res_model and attachment_sudo.res_id):
# do not return system attachment not linked to a record
return {}
if len(self.env.context.get('active_ids', [])) > 1:
# In mass mail mode 'attachments_ids' is removed from template values
# as they should not be rendered
return {'attachments': [(attachment_sudo.name, attachment_sudo.datas)]}
return {'attachment_ids': attachment_sudo.ids}

View file

@ -1,26 +1,7 @@
# -*- 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
from odoo.osv import expression
from odoo import api, fields, models
from odoo.tools import html_escape
from odoo.exceptions import RedirectWarning
try:
from PyPDF2.errors import PdfReadError
except ImportError:
from PyPDF2.utils import PdfReadError
from lxml import etree
from struct import error as StructError
import base64
import io
import logging
import pathlib
import re
_logger = logging.getLogger(__name__)
class AccountEdiFormat(models.Model):
@ -30,9 +11,10 @@ class AccountEdiFormat(models.Model):
name = fields.Char()
code = fields.Char(required=True)
_sql_constraints = [
('unique_code', 'unique (code)', 'This code already exists')
]
_unique_code = models.Constraint(
'unique (code)',
'This code already exists',
)
####################################################
# Low-level methods
@ -127,79 +109,6 @@ class AccountEdiFormat(models.Model):
# Import methods to override based on EDI Format
####################################################
def _create_invoice_from_xml_tree(self, filename, tree, journal=None):
""" 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.
:param journal: The journal on which importing the invoice.
: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']
def _prepare_invoice_report(self, pdf_writer, edi_document):
"""
Prepare invoice report to be printed.
@ -209,363 +118,6 @@ class AccountEdiFormat(models.Model):
# TO OVERRIDE
self.ensure_one()
####################################################
# 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.warning("Error when reading the pdf: %s", e, exc_info=True)
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, StructError, PdfReadError) 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 = []
# XML attachments received by mail have a 'text/plain' mimetype (cfr. context key: 'attachments_mime_plainxml')
# Therefore, if content start with '<?xml', or if the filename ends with '.xml', it is considered as XML.
is_text_plain_xml = 'text/plain' in attachment.mimetype and (content.startswith(b'<?xml') or attachment.name.endswith('.xml'))
if 'pdf' in attachment.mimetype:
to_process.extend(self._decode_pdf(attachment.name, content))
elif attachment.mimetype.endswith('/xml') or is_text_plain_xml:
to_process.extend(self._decode_xml(attachment.name, content))
else:
to_process.extend(self._decode_binary(attachment.name, content))
return to_process
def _create_document_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.with_company(self.env.company)._create_invoice_from_xml_tree(file_data['filename'], file_data['xml_tree'])
elif file_data['type'] == 'pdf':
res = edi_format.with_company(self.env.company)._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 RedirectWarning as rw:
raise rw
except Exception as e:
_logger.exception(
"Error importing attachment \"%s\" as invoice with format \"%s\": %s",
file_data['filename'],
edi_format.name,
str(e))
if res:
return res._link_invoice_origin_to_purchase_orders(timeout=4)
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.with_company(invoice.company_id)._update_invoice_from_xml_tree(file_data['filename'], file_data['xml_tree'], invoice)
elif file_data['type'] == 'pdf':
res = edi_format.with_company(invoice.company_id)._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\": %s",
file_data['filename'],
edi_format.name,
str(e))
if res:
return res._link_invoice_origin_to_purchase_orders(timeout=4)
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
@api.model
def _retrieve_partner_with_vat(self, vat, extra_domain):
if not vat:
return None
# Sometimes, the vat is specified with some whitespaces.
normalized_vat = vat.replace(' ', '')
country_prefix = re.match('^[a-zA-Z]{2}|^', vat).group()
partner = self.env['res.partner'].search(extra_domain + [('vat', 'in', (normalized_vat, vat))], limit=1)
# Try to remove the country code prefix from the vat.
if not partner and country_prefix:
partner = self.env['res.partner'].search(extra_domain + [
('vat', 'in', (normalized_vat[2:], vat[2:])),
('country_id.code', '=', country_prefix.upper()),
], limit=1)
# The country could be not specified on the partner.
if not partner:
partner = self.env['res.partner'].search(extra_domain + [
('vat', 'in', (normalized_vat[2:], vat[2:])),
('country_id', '=', False),
], limit=1)
# The vat could be a string of alphanumeric values without country code but with missing zeros at the
# beginning.
if not partner:
try:
vat_only_numeric = str(int(re.sub(r'^\D{2}', '', normalized_vat) or 0))
except ValueError:
vat_only_numeric = None
if vat_only_numeric:
query = self.env['res.partner']._where_calc(extra_domain + [('active', '=', True)])
tables, where_clause, where_params = query.get_sql()
if country_prefix:
vat_prefix_regex = f'({country_prefix})?'
else:
vat_prefix_regex = '([A-z]{2})?'
self._cr.execute(f'''
SELECT res_partner.id
FROM {tables}
WHERE {where_clause}
AND res_partner.vat ~ %s
LIMIT 1
''', where_params + ['^%s0*%s$' % (vat_prefix_regex, vat_only_numeric)])
partner_row = self._cr.fetchone()
if partner_row:
partner = self.env['res.partner'].browse(partner_row[0])
return partner
@api.model
def _retrieve_partner_with_phone_mail(self, phone, mail, extra_domain):
domains = []
if phone:
domains.append([('phone', '=', phone)])
domains.append([('mobile', '=', phone)])
if mail:
domains.append([('email', '=', mail)])
if not domains:
return None
domain = expression.OR(domains)
if extra_domain:
domain = expression.AND([domain, extra_domain])
return self.env['res.partner'].search(domain, limit=1)
@api.model
def _retrieve_partner_with_name(self, name, extra_domain):
if not name:
return None
return self.env['res.partner'].search([('name', 'ilike', name)] + extra_domain, limit=1)
def _retrieve_partner(self, name=None, phone=None, mail=None, vat=None, domain=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.
'''
def search_with_vat(extra_domain):
return self._retrieve_partner_with_vat(vat, extra_domain)
def search_with_phone_mail(extra_domain):
return self._retrieve_partner_with_phone_mail(phone, mail, extra_domain)
def search_with_name(extra_domain):
return self._retrieve_partner_with_name(name, extra_domain)
def search_with_domain(extra_domain):
if not domain:
return None
return self.env['res.partner'].search(domain + extra_domain, limit=1)
for search_method in (search_with_vat, search_with_domain, search_with_phone_mail, search_with_name):
for extra_domain in ([('company_id', '=', self.env.company.id)], [('company_id', '=', False)]):
partner = search_method(extra_domain)
if partner:
return partner
return self.env['res.partner']
def _retrieve_product(self, name=None, default_code=None, barcode=None):
'''Search all products and find one that matches one of the parameters.
Use the following priority:
1. barcode
2. default_code
3. name (exact match)
4. name (ilike)
: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.
'''
if name and '\n' in name:
# cut Sales Description from the name
name = name.split('\n')[0]
domains = []
if barcode:
domains.append([('barcode', '=', barcode)])
if default_code:
domains.append([('default_code', '=', default_code)])
if name:
domains += [[('name', '=', name)], [('name', 'ilike', name)]]
products = self.env['product.product'].search(
expression.AND([
expression.OR(domains),
[('company_id', 'in', [False, self.env.company.id])],
]),
)
if products:
for domain in domains:
products_by_domain = products.filtered_domain(domain)
if products_by_domain:
return products_by_domain[0]
return self.env['product.product']
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)],
[('company_id', '=', self.env.company.id)]
]
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.
'''
currency = self.env['res.currency'].with_context(active_test=False).search([('name', '=', code.upper())], limit=1)
if currency and not currency.active:
error_msg = _('The currency (%s) of the document you are uploading is not active in this database.\n'
'Please activate it and update the currency rate if needed before trying again to import.',
currency.name)
error_action = {
'view_mode': 'form',
'res_model': 'res.currency',
'type': 'ir.actions.act_window',
'target': 'new',
'res_id': currency.id,
'views': [[False, 'form']]
}
raise RedirectWarning(error_msg, error_action, _('Display the currency'))
return currency
####################################################
# Other helpers
####################################################

View file

@ -57,7 +57,7 @@ class AccountJournal(models.Model):
journal_ids = self.ids
if journal_ids:
self._cr.execute('''
self.env.cr.execute('''
SELECT
move.journal_id,
ARRAY_AGG(doc.edi_format_id) AS edi_format_ids
@ -67,7 +67,7 @@ class AccountJournal(models.Model):
AND move.journal_id IN %s
GROUP BY move.journal_id
''', [tuple(journal_ids)])
protected_edi_formats_per_journal = {r[0]: set(r[1]) for r in self._cr.fetchall()}
protected_edi_formats_per_journal = {r[0]: set(r[1]) for r in self.env.cr.fetchall()}
else:
protected_edi_formats_per_journal = defaultdict(set)

View file

@ -1,5 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import io
import zipfile
from werkzeug.urls import url_encode
from odoo import api, fields, models, _
from odoo.exceptions import UserError
@ -18,7 +23,7 @@ class AccountMove(models.Model):
help='The aggregated state of all the EDIs with web-service of this move')
edi_error_count = fields.Integer(
compute='_compute_edi_error_count',
help='How many EDIs are in error for this move ?')
help='How many EDIs are in error for this move?')
edi_blocking_level = fields.Selection(
selection=[('info', 'Info'), ('warning', 'Warning'), ('error', 'Error')],
compute='_compute_edi_error_message')
@ -31,6 +36,8 @@ class AccountMove(models.Model):
compute='_compute_edi_show_cancel_button')
edi_show_abandon_cancel_button = fields.Boolean(
compute='_compute_edi_show_abandon_cancel_button')
edi_show_force_cancel_button = fields.Boolean(
compute='_compute_edi_show_force_cancel_button')
@api.depends('edi_document_ids.state')
def _compute_edi_state(self):
@ -47,6 +54,11 @@ class AccountMove(models.Model):
else:
move.edi_state = False
@api.depends('edi_document_ids.state')
def _compute_edi_show_force_cancel_button(self):
for move in self:
move.edi_show_force_cancel_button = move._can_force_cancel()
@api.depends('edi_document_ids.error')
def _compute_edi_error_count(self):
for move in self:
@ -64,14 +76,15 @@ class AccountMove(models.Model):
move.edi_blocking_level = error_doc.blocking_level
else:
error_levels = set([doc.blocking_level for doc in move.edi_document_ids])
count = str(move.edi_error_count)
if 'error' in error_levels:
move.edi_error_message = str(move.edi_error_count) + _(" Electronic invoicing error(s)")
move.edi_error_message = _("%(count)s Electronic invoicing error(s)", count=count)
move.edi_blocking_level = 'error'
elif 'warning' in error_levels:
move.edi_error_message = str(move.edi_error_count) + _(" Electronic invoicing warning(s)")
move.edi_error_message = _("%(count)s Electronic invoicing warning(s)", count=count)
move.edi_blocking_level = 'warning'
else:
move.edi_error_message = str(move.edi_error_count) + _(" Electronic invoicing info(s)")
move.edi_error_message = _("%(count)s Electronic invoicing info(s)", count=count)
move.edi_blocking_level = 'info'
@api.depends(
@ -126,7 +139,7 @@ class AccountMove(models.Model):
def _compute_edi_show_abandon_cancel_button(self):
for move in self:
move.edi_show_abandon_cancel_button = False
for doc in move.edi_document_ids:
for doc in move.sudo().edi_document_ids:
move_applicability = doc.edi_format_id._get_move_applicability(move)
if doc.edi_format_id._needs_web_services() \
and doc.state == 'to_cancel' \
@ -168,22 +181,6 @@ class AccountMove(models.Model):
grouping_key to aggregate tax values together. The returned dictionary is added
to each tax details in order to retrieve the full grouping_key later.
:param compute_mode: Optional parameter to specify the method used to allocate the tax line amounts
among the invoice lines:
'tax_details' (the default) uses the AccountMove._get_query_tax_details method.
'compute_all' uses the AccountTax._compute_all method.
The 'tax_details' method takes the tax line balance and allocates it among the
invoice lines to which that tax applies, proportionately to the invoice lines'
base amounts. This always ensures that the sum of the tax amounts equals the
tax line's balance, which, depending on the constraints of a particular
localization, can be more appropriate when 'Round Globally' is set.
The 'compute_all' method returns, for each invoice line, the exact tax amounts
corresponding to the taxes applied to the invoice line. Depending on the
constraints of the particular localization, this can be more appropriate when
'Round per Line' is set.
:return: The full tax details for the current invoice and for each invoice line
separately. The returned dictionary is the following:
@ -222,73 +219,6 @@ class AccountMove(models.Model):
grouping_key_generator=grouping_key_generator,
)
def _prepare_edi_vals_to_export(self):
''' The purpose of this helper is to prepare values in order to export an invoice through the EDI system.
This includes the computation of the tax details for each invoice line that could be very difficult to
handle regarding the computation of the base amount.
:return: A python dict containing default pre-processed values.
'''
self.ensure_one()
res = {
'record': self,
'balance_multiplicator': -1 if self.is_inbound() else 1,
'invoice_line_vals_list': [],
}
# Invoice lines details.
for index, line in enumerate(self.invoice_line_ids.filtered(lambda line: line.display_type == 'product'), start=1):
line_vals = line._prepare_edi_vals_to_export()
line_vals['index'] = index
res['invoice_line_vals_list'].append(line_vals)
# Totals.
res.update({
'total_price_subtotal_before_discount': sum(x['price_subtotal_before_discount'] for x in res['invoice_line_vals_list']),
'total_price_discount': sum(x['price_discount'] for x in res['invoice_line_vals_list']),
})
return res
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 created when the payment is fully reconciled
with invoices.
'''
payments = self.filtered(lambda move: move.payment_id or move.statement_line_id)
edi_document_vals_list = []
to_remove = self.env['account.edi.document']
for payment in payments:
edi_formats = payment._get_reconciled_invoices().journal_id.edi_format_ids | payment.edi_document_ids.edi_format_id
for edi_format in edi_formats:
# Only recreate document when cancelled before.
existing_edi_document = payment.edi_document_ids.filtered(lambda x: x.edi_format_id == edi_format)
if existing_edi_document.state == 'sent':
continue
move_applicability = edi_format._get_move_applicability(payment)
if move_applicability:
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:
to_remove |= existing_edi_document
to_remove.unlink()
self.env['account.edi.document'].create(edi_document_vals_list)
payments.edi_document_ids._process_documents_no_web_services()
def _is_ready_to_be_sent(self):
# OVERRIDE
# Prevent a mail to be sent to the customer if the EDI document is not sent.
@ -313,7 +243,7 @@ class AccountMove(models.Model):
if move_applicability:
errors = edi_format._check_move_configuration(move)
if errors:
raise UserError(_("Invalid invoice configuration:\n\n%s") % '\n'.join(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:
@ -330,9 +260,18 @@ class AccountMove(models.Model):
self.env['account.edi.document'].create(edi_document_vals_list)
posted.edi_document_ids._process_documents_no_web_services()
self.env.ref('account_edi.ir_cron_edi_network')._trigger()
if not self.env.context.get('skip_account_edi_cron_trigger'):
self.env.ref('account_edi.ir_cron_edi_network')._trigger()
return posted
def button_force_cancel(self):
""" Cancel the invoice without waiting for the cancellation request to succeed.
"""
for move in self:
to_cancel_edi_documents = move.edi_document_ids.filtered(lambda doc: doc.state == 'to_cancel')
move.message_post(body=_("This invoice was canceled while the EDIs %s still had a pending cancellation request.", ", ".join(to_cancel_edi_documents.mapped('edi_format_id.name'))))
self.button_cancel()
def button_cancel(self):
# OVERRIDE
# Set the electronic document to be canceled and cancel immediately for synchronous formats.
@ -355,8 +294,8 @@ class AccountMove(models.Model):
if not move._edi_allow_button_draft():
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)
"sent. Please use the 'Request EDI Cancellation' button instead.",
move.display_name))
res = super().button_draft()
@ -370,7 +309,7 @@ class AccountMove(models.Model):
'''
to_cancel_documents = self.env['account.edi.document']
for move in self:
move._check_fiscalyear_lock_date()
move._check_fiscal_lock_dates()
is_move_marked = False
for doc in move.edi_document_ids:
move_applicability = doc.edi_format_id._get_move_applicability(move)
@ -407,27 +346,11 @@ class AccountMove(models.Model):
def _get_edi_attachment(self, edi_format):
return self._get_edi_document(edi_format).sudo().attachment_id
####################################################
# Import Electronic Document
####################################################
def _get_create_document_from_attachment_decoders(self):
# OVERRIDE
res = super()._get_create_document_from_attachment_decoders()
res.append((10, self.env['account.edi.format'].search([])._create_document_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
# this override is to make sure that the main attachment is not the edi xml otherwise the attachment viewer will not work correctly
def _message_set_main_attachment_id(self, attachment_ids):
if self.message_main_attachment_id and len(attachment_ids) > 1 and self.message_main_attachment_id in self.edi_document_ids.attachment_id:
self.message_main_attachment_id = self.env['ir.attachment']
super()._message_set_main_attachment_id(attachment_ids)
def _message_set_main_attachment_id(self, attachments, force=False, filter_xml=True):
if not force and len(attachments) > 1 and self.message_main_attachment_id in self.edi_document_ids.attachment_id:
force = True
super()._message_set_main_attachment_id(attachments, force=force, filter_xml=filter_xml)
####################################################
# Business operations
@ -441,60 +364,26 @@ class AccountMove(models.Model):
docs = self.edi_document_ids.filtered(lambda d: d.state in ('to_send', 'to_cancel') and d.blocking_level != 'error')
docs._process_documents_web_services(with_commit=with_commit)
def _retry_edi_documents_error_hook(self):
''' Hook called when edi_documents are retried. For example, when it's needed to clean a field.
TO OVERRIDE
def _retry_edi_documents_error(self):
'''Called when edi_documents need to be retried.
'''
return
self.edi_document_ids.write({'error': False, 'blocking_level': False})
def action_retry_edi_documents_error(self):
self._retry_edi_documents_error_hook()
self.edi_document_ids.write({'error': False, 'blocking_level': False})
self._retry_edi_documents_error()
self.action_process_edi_web_services()
class AccountMoveLine(models.Model):
_inherit = 'account.move.line'
####################################################
# Export Electronic Document
# Mailing
####################################################
def _prepare_edi_vals_to_export(self):
''' The purpose of this helper is the same as '_prepare_edi_vals_to_export' but for a single invoice line.
This includes the computation of the tax details for each invoice line or the management of the discount.
Indeed, in some EDI, we need to provide extra values depending the discount such as:
- the discount as an amount instead of a percentage.
- the price_unit but after subtraction of the discount.
:return: A python dict containing default pre-processed values.
'''
self.ensure_one()
if self.discount == 100.0:
gross_price_subtotal = self.currency_id.round(self.price_unit * self.quantity)
else:
gross_price_subtotal = self.currency_id.round(self.price_subtotal / (1 - self.discount / 100.0))
res = {
'line': self,
'price_unit_after_discount': self.currency_id.round(self.price_unit * (1 - (self.discount / 100.0))),
'price_subtotal_before_discount': gross_price_subtotal,
'price_subtotal_unit': self.currency_id.round(self.price_subtotal / self.quantity) if self.quantity else 0.0,
'price_total_unit': self.currency_id.round(self.price_total / self.quantity) if self.quantity else 0.0,
'price_discount': gross_price_subtotal - self.price_subtotal,
'price_discount_unit': (gross_price_subtotal - self.price_subtotal) / self.quantity if self.quantity else 0.0,
'gross_price_total_unit': self.currency_id.round(gross_price_subtotal / self.quantity) if self.quantity else 0.0,
'unece_uom_code': self.product_id.product_tmpl_id.uom_id._get_unece_code(),
}
return res
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
res = super().reconcile()
all_lines.move_id._update_payments_edi_documents()
return res
def _process_attachments_for_template_post(self, mail_template):
""" Add Edi attachments to templates. """
result = super()._process_attachments_for_template_post(mail_template)
for move in self.filtered('edi_document_ids'):
move_result = result.setdefault(move.id, {})
for edi_doc in move.edi_document_ids:
edi_attachments = edi_doc._filter_edi_attachments_for_mailing()
move_result.setdefault('attachment_ids', []).extend(edi_attachments.get('attachment_ids', []))
move_result.setdefault('attachments', []).extend(edi_attachments.get('attachments', []))
return result

View file

@ -0,0 +1,19 @@
from odoo import api, models
class AccountMoveSend(models.AbstractModel):
_inherit = 'account.move.send'
@api.model
def _get_mail_attachment_from_doc(self, doc):
attachment_sudo = doc.sudo().attachment_id
if attachment_sudo.res_model and attachment_sudo.res_id:
return attachment_sudo
return self.env['ir.attachment']
def _get_invoice_extra_attachments(self, move):
# EXTENDS 'account'
result = super()._get_invoice_extra_attachments(move)
for doc in move.edi_document_ids:
result += self._get_mail_attachment_from_doc(doc)
return result

View file

@ -1,15 +0,0 @@
# -*- 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()
def action_retry_edi_documents_error(self):
self.ensure_one()
return self.move_id.action_retry_edi_documents_error()

View file

@ -32,10 +32,6 @@ class IrActionsReport(models.Model):
# Post-process and embed the additional files.
writer = OdooPdfFileWriter()
writer.cloneReaderDocumentRoot(reader)
# Copy all pages from the reader to the writer (required for PyPDF2 3.x)
for page_num in range(reader.getNumPages()):
page = reader.getPage(page_num)
writer.addPage(page)
for edi_document in to_embed:
# The attachements on the edi documents are only system readable
# because they don't have res_id and res_model, here we are sure that

View file

@ -1,47 +0,0 @@
# -*- coding: utf-8 -*-
import io
from odoo import models
from odoo.tools.pdf import OdooPdfFileReader, OdooPdfFileWriter
class IrActionsReport(models.Model):
_inherit = 'ir.actions.report'
def _render_qweb_pdf_prepare_streams(self, report_ref, data, res_ids=None):
# EXTENDS base
collected_streams = super()._render_qweb_pdf_prepare_streams(report_ref, data, res_ids=res_ids)
if collected_streams \
and res_ids \
and len(res_ids) == 1 \
and self._is_invoice_report(report_ref):
invoice = self.env['account.move'].browse(res_ids)
if invoice.is_sale_document() and invoice.state != 'draft':
to_embed = invoice.edi_document_ids
# Add the attachments to the pdf file
if to_embed:
pdf_stream = collected_streams[invoice.id]['stream']
# Read pdf content.
pdf_content = pdf_stream.getvalue()
reader_buffer = io.BytesIO(pdf_content)
reader = OdooPdfFileReader(reader_buffer, strict=False)
# Post-process and embed the additional files.
writer = OdooPdfFileWriter()
writer.cloneReaderDocumentRoot(reader)
for edi_document in to_embed:
# The attachements on the edi documents are only system readable
# because they don't have res_id and res_model, here we are sure that
# the user has access to the invoice and edi document
edi_document.edi_format_id._prepare_invoice_report(writer, edi_document)
# Replace the current content.
pdf_stream.close()
new_pdf_stream = io.BytesIO()
writer.write(new_pdf_stream)
collected_streams[invoice.id]['stream'] = new_pdf_stream
return collected_streams

View file

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
from odoo import api, models, fields, _
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models, _
from odoo.exceptions import UserError
@ -8,7 +9,8 @@ class IrAttachment(models.Model):
@api.ondelete(at_uninstall=False)
def _unlink_except_government_document(self):
linked_edi_documents = self.env['account.edi.document'].search([('attachment_id', 'in', self.ids)])
# sudo: account.edi.document - constraint that must be applied regardless of ACL
linked_edi_documents = self.env['account.edi.document'].sudo().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."))

View file

@ -1,58 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models
class MailTemplate(models.Model):
_inherit = "mail.template"
def _get_edi_attachments(self, document):
"""
Will either return the information about the attachment of the edi document for adding the attachment in the
mail, or the attachment id to be linked to the 'send & print' wizard.
Can be overridden where e.g. a zip-file needs to be sent with the individual files instead of the entire zip
IMPORTANT:
* If the attachment's id is returned, no new attachment will be created, the existing one on the move is linked
to the wizard (see _onchange_template_id in mail.compose.message).
* If the attachment's content is returned, a new one is created and linked to the wizard. Thus, when sending
the mail (clicking on 'send & print' in the wizard), a new attachment is added to the move (see
_action_send_mail in mail.compose.message).
:param document: an edi document
:return: dict:
{'attachments': tuple with the name and base64 content of the attachment}
OR
{'attachment_ids': list containing the id of the attachment}
"""
attachment_sudo = document.sudo().attachment_id
if not attachment_sudo:
return {}
if not (attachment_sudo.res_model and attachment_sudo.res_id):
# do not return system attachment not linked to a record
return {}
if len(self._context.get('active_ids', [])) > 1:
# In mass mail mode 'attachments_ids' is removed from template values
# as they should not be rendered
return {'attachments': [(attachment_sudo.name, attachment_sudo.datas)]}
return {'attachment_ids': [attachment_sudo.id]}
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:
record_data.setdefault('attachments', [])
attachments = self._get_edi_attachments(doc)
record_data['attachment_ids'] += attachments.get('attachment_ids', [])
record_data['attachments'] += attachments.get('attachments', [])
return res

View file

@ -1,38 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models
class UoM(models.Model):
_inherit = 'uom.uom'
def _get_unece_code(self):
""" Returns the UNECE code used for international trading for corresponding to the UoM as per
https://unece.org/fileadmin/DAM/cefact/recommendations/rec20/rec20_rev3_Annex2e.pdf"""
mapping = {
'uom.product_uom_unit': 'C62',
'uom.product_uom_dozen': 'DZN',
'uom.product_uom_kgm': 'KGM',
'uom.product_uom_gram': 'GRM',
'uom.product_uom_day': 'DAY',
'uom.product_uom_hour': 'HUR',
'uom.product_uom_ton': 'TNE',
'uom.product_uom_meter': 'MTR',
'uom.product_uom_km': 'KMT',
'uom.product_uom_cm': 'CMT',
'uom.product_uom_litre': 'LTR',
'uom.product_uom_lb': 'LBR',
'uom.product_uom_oz': 'ONZ',
'uom.product_uom_inch': 'INH',
'uom.product_uom_foot': 'FOT',
'uom.product_uom_mile': 'SMI',
'uom.product_uom_floz': 'OZA',
'uom.product_uom_qt': 'QT',
'uom.product_uom_gal': 'GLL',
'uom.product_uom_cubic_meter': 'MTQ',
'uom.product_uom_cubic_inch': 'INQ',
'uom.product_uom_cubic_foot': 'FTQ',
}
xml_ids = self._get_external_ids().get(self.id, [])
matches = list(set(xml_ids) & set(mapping.keys()))
return matches and mapping[matches[0]] or 'C62'