Files
odoo_source/addons/account_edi/models/account_edi_document.py
T
Adrien Minet c32699d192 [FIX] account_edi: documents of draft invoice no longer sent
How to reproduce the bug ?

- install point_of_sale,l10n_mx_edi
- In Point of Sale > Settings, add at least one Payment Methods
- Still in Point of Sale > Settings, create one Point of Sale
- In Point of Sale > Products > Products, select one product.
- In the Accounting tab of the product, set the UNSPSC Category.
- Go back on the dashboard of Point of Sale and start a new session.
- Add the product you have selected before and go to calidate the
invoice.
- Pick a payment methode and a costumer and check the invoice option.
- Validate the invoice.
- Close the session and go to Point of Sale > Orders > Orders.
- Click on the order you have just created and click on Invoice (in the
top right corner).
- Reset the invoice in draft.
- Wait for the cron task to be executed or execute it manually.

What is the bug ?

The cron task that send all the edi documents doesn't consider the state
of the account_move linked to it. Because of that if an invoice poster
is reset to draft, it will have a document and this document will be
sent.

opw-2925137

closes odoo/odoo#97648

X-original-commit: b27ff3c3c610102b2254acbad3801f21f0b36ef3
Signed-off-by: Adrien Minet <admi@odoo.com>
Signed-off-by: Josse Colpaert <jco@odoo.com>
2022-08-08 23:45:42 +02:00

266 lines
13 KiB
Python

# -*- 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
_logger = logging.getLogger(__name__)
DEFAULT_BLOCKING_LEVEL = 'error'
class AccountEdiDocument(models.Model):
_name = 'account.edi.document'
_description = 'Electronic Document for an account.move'
# == Stored fields ==
move_id = fields.Many2one('account.move', required=True, ondelete='cascade')
edi_format_id = fields.Many2one('account.edi.format', required=True)
attachment_id = fields.Many2one('ir.attachment', help='The file generated by edi_format_id when the invoice is posted (and this document is processed).')
state = fields.Selection([('to_send', 'To Send'), ('sent', 'Sent'), ('to_cancel', 'To Cancel'), ('cancelled', 'Cancelled')])
error = fields.Html(help='The text of the last error that happened during Electronic Invoice operation.')
blocking_level = fields.Selection(
selection=[('info', 'Info'), ('warning', 'Warning'), ('error', 'Error')],
help="Blocks the current operation of the document depending on the error severity:\n"
" * Info: the document is not blocked and everything is working as it should.\n"
" * Warning: there is an error that doesn't prevent the current Electronic Invoicing operation to succeed.\n"
" * Error: there is an error that blocks the current Electronic Invoicing operation.")
# == 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)
_sql_constraints = [
(
'unique_edi_document_by_move_by_format',
'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):
for doc in self:
res = b''
if doc.state in ('to_send', 'to_cancel'):
move = doc.move_id
config_errors = doc.edi_format_id._check_move_configuration(move)
if config_errors:
res = base64.b64encode('\n'.join(config_errors).encode('UTF-8'))
elif move.is_invoice(include_receipts=True) and doc.edi_format_id._is_required_for_invoice(move):
res = base64.b64encode(doc.edi_format_id._get_invoice_edi_content(doc.move_id))
elif move.payment_id and doc.edi_format_id._is_required_for_payment(move):
res = base64.b64encode(doc.edi_format_id._get_payment_edi_content(doc.move_id))
doc.edi_content = res
def action_export_xml(self):
self.ensure_one()
return {
'type': 'ir.actions.act_url',
'url': '/web/content/account.edi.document/%s/edi_content' % self.id
}
def _prepare_jobs(self):
"""Creates a list of jobs to be performed by '_process_job' for the documents in self.
Each document represent a job, BUT if multiple documents have the same state, edi_format_id,
doc_type (invoice or payment) and company_id AND the edi_format_id supports batching, they are grouped
into a single job.
:returns: A list of tuples (documents, doc_type)
* documents: The documents related to this job. If edi_format_id does not support batch, length is one
* doc_type: Are the moves of this job invoice or payments ?
"""
# Classify jobs by (edi_format, edi_doc.state, doc_type, move.company_id, custom_key)
to_process = {}
documents = self.filtered(lambda d: d.state in ('to_send', 'to_cancel') and d.blocking_level != 'error')
for edi_doc in documents:
move = edi_doc.move_id
edi_format = edi_doc.edi_format_id
if move.is_invoice(include_receipts=True):
doc_type = 'invoice'
elif move.payment_id or move.statement_line_id:
doc_type = 'payment'
else:
continue
custom_key = edi_format._get_batch_key(edi_doc.move_id, edi_doc.state)
key = (edi_format, edi_doc.state, doc_type, move.company_id, custom_key)
to_process.setdefault(key, self.env['account.edi.document'])
to_process[key] |= edi_doc
# Order payments/invoice and create batches.
invoices = []
payments = []
for key, documents in to_process.items():
edi_format, state, doc_type, company_id, custom_key = key
target = invoices if doc_type == 'invoice' else payments
batch = self.env['account.edi.document']
for doc in documents:
if edi_format._support_batching(move=doc.move_id, state=state, company=company_id):
batch |= doc
else:
target.append((doc, doc_type))
if batch:
target.append((batch, doc_type))
return invoices + payments
@api.model
def _process_job(self, documents, doc_type):
"""Post or cancel move_id (invoice or payment) by calling the related methods on edi_format_id.
Invoices are processed before payments.
:param documents: The documents related to this job. If edi_format_id does not support batch, length is one
:param doc_type: Are the moves of this job invoice or payments ?
"""
def _postprocess_post_edi_results(documents, edi_result):
attachments_to_unlink = self.env['ir.attachment']
for document in documents:
move = document.move_id
move_result = edi_result.get(move, {})
if move_result.get('attachment'):
old_attachment = document.attachment_id
document.attachment_id = move_result['attachment']
if not old_attachment.res_model or not old_attachment.res_id:
attachments_to_unlink |= old_attachment
if move_result.get('success') is True:
document.write({
'state': 'sent',
'error': False,
'blocking_level': False,
})
else:
document.write({
'error': move_result.get('error', False),
'blocking_level': move_result.get('blocking_level', DEFAULT_BLOCKING_LEVEL) if 'error' in move_result else False,
})
# Attachments that are not explicitly linked to a business model could be removed because they are not
# supposed to have any traceability from the user.
attachments_to_unlink.unlink()
def _postprocess_cancel_edi_results(documents, edi_result):
invoice_ids_to_cancel = set() # Avoid duplicates
attachments_to_unlink = self.env['ir.attachment']
for document in documents:
move = document.move_id
move_result = edi_result.get(move, {})
if move_result.get('success') is True:
old_attachment = document.attachment_id
document.write({
'state': 'cancelled',
'error': False,
'attachment_id': False,
'blocking_level': False,
})
if move.is_invoice(include_receipts=True) and move.state == 'posted':
# The user requested a cancellation of the EDI and it has been approved. Then, the invoice
# can be safely cancelled.
invoice_ids_to_cancel.add(move.id)
if not old_attachment.res_model or not old_attachment.res_id:
attachments_to_unlink |= old_attachment
else:
document.write({
'error': move_result.get('error', False),
'blocking_level': move_result.get('blocking_level', DEFAULT_BLOCKING_LEVEL) if move_result.get('error') else False,
})
if invoice_ids_to_cancel:
invoices = self.env['account.move'].browse(list(invoice_ids_to_cancel))
invoices.button_draft()
invoices.button_cancel()
# Attachments that are not explicitly linked to a business model could be removed because they are not
# supposed to have any traceability from the user.
attachments_to_unlink.unlink()
documents.edi_format_id.ensure_one() # All account.edi.document of a job should have the same edi_format_id
documents.move_id.company_id.ensure_one() # All account.edi.document of a job should be from the same company
if len(set(doc.state for doc in documents)) != 1:
raise ValueError('All account.edi.document of a job should have the same state')
edi_format = documents.edi_format_id
state = documents[0].state
documents.move_id.line_ids.flush_recordset() # manual flush for tax details
if doc_type == 'invoice':
if state == 'to_send':
invoices = documents.move_id
with invoices._send_only_when_ready():
edi_result = edi_format._post_invoice_edi(invoices)
_postprocess_post_edi_results(documents, edi_result)
elif state == 'to_cancel':
edi_result = edi_format._cancel_invoice_edi(documents.move_id)
_postprocess_cancel_edi_results(documents, edi_result)
elif doc_type == 'payment':
if state == 'to_send':
edi_result = edi_format._post_payment_edi(documents.move_id)
_postprocess_post_edi_results(documents, edi_result)
elif state == 'to_cancel':
edi_result = edi_format._cancel_payment_edi(documents.move_id)
_postprocess_cancel_edi_results(documents, edi_result)
def _process_documents_no_web_services(self):
""" Post and cancel all the documents that don't need a web service.
"""
jobs = self.filtered(lambda d: not d.edi_format_id._needs_web_services())._prepare_jobs()
for documents, doc_type in jobs:
self._process_job(documents, doc_type)
def _process_documents_web_services(self, job_count=None, with_commit=True):
''' Post and cancel all the documents that need a web service.
:param job_count: The maximum number of jobs to process if specified.
:param with_commit: Flag indicating a commit should be made between each job.
:return: The number of remaining jobs to process.
'''
all_jobs = self.filtered(lambda d: d.edi_format_id._needs_web_services())._prepare_jobs()
jobs_to_process = all_jobs[0:job_count] if job_count else all_jobs
for documents, doc_type in jobs_to_process:
move_to_lock = documents.move_id
attachments_potential_unlink = documents.attachment_id.filtered(lambda a: not a.res_model and not a.res_id)
try:
with self.env.cr.savepoint(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
self._process_job(documents, doc_type)
if with_commit and len(jobs_to_process) > 1:
self.env.cr.commit()
return len(all_jobs) - len(jobs_to_process)
@api.model
def _cron_process_documents_web_services(self, job_count=None):
''' Method called by the EDI cron processing all web-services.
:param job_count: Limit explicitely the number of web service calls. If not provided, process all.
'''
edi_documents = self.search([('state', 'in', ('to_send', 'to_cancel')), ('move_id.state', '=', 'posted')])
nb_remaining_jobs = edi_documents._process_documents_web_services(job_count=job_count)
# 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()