- Some errors will never get fixed until user intervention, it doesn't make sense to run the CRON when there is such error. + some small improvements and esthetic changes - When we are in a CRON, we need to commit the changes between each call to web-service to avoid loss of data. - Small refactor of edi.document prepare_jobs and process_jobs - Added an arbitrary key to create the batches.
258 lines
13 KiB
Python
258 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.addons.account_edi_extended.models.account_edi_document import DEFAULT_BLOCKING_LEVEL
|
|
from psycopg2 import OperationalError
|
|
import logging
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class AccountEdiDocument(models.Model):
|
|
_name = 'account.edi.document'
|
|
_description = 'Electronic Document for an account.move'
|
|
|
|
# == Stored fields ==
|
|
move_id = fields.Many2one('account.move', required=True, ondelete='cascade')
|
|
edi_format_id = fields.Many2one('account.edi.format', required=True)
|
|
attachment_id = fields.Many2one('ir.attachment', help='The file generated by edi_format_id when the invoice is posted (and this document is processed).')
|
|
state = fields.Selection([('to_send', 'To Send'), ('sent', 'Sent'), ('to_cancel', 'To Cancel'), ('cancelled', 'Cancelled')])
|
|
error = fields.Html(help='The text of the last error that happened during Electronic Invoice operation.')
|
|
|
|
# == Not stored fields ==
|
|
name = fields.Char(related='attachment_id.name')
|
|
edi_format_name = fields.Char(string='Format Name', related='edi_format_id.name')
|
|
|
|
_sql_constraints = [
|
|
(
|
|
'unique_edi_document_by_move_by_format',
|
|
'UNIQUE(edi_format_id, move_id)',
|
|
'Only one edi document by move by format',
|
|
),
|
|
]
|
|
|
|
def write(self, vals):
|
|
''' If account_edi_extended is not installed, a default behaviour is used instead.
|
|
'''
|
|
if 'blocking_level' in vals and 'blocking_level' not in self.env['account.edi.document']._fields:
|
|
vals.pop('blocking_level')
|
|
|
|
return super().write(vals)
|
|
|
|
def _prepare_jobs(self):
|
|
"""Creates a list of jobs to be performed by '_process_job' for the documents in self.
|
|
Each document represent a job, BUT if multiple documents have the same state, edi_format_id,
|
|
doc_type (invoice or payment) and company_id AND the edi_format_id supports batching, they are grouped
|
|
into a single job.
|
|
|
|
:returns: A list of tuples (documents, doc_type)
|
|
* documents: The documents related to this job. If edi_format_id does not support batch, length is one
|
|
* doc_type: Are the moves of this job invoice or payments ?
|
|
"""
|
|
|
|
# Classify jobs by (edi_format, edi_doc.state, doc_type, move.company_id, custom_key)
|
|
to_process = {}
|
|
if 'blocking_level' in self.env['account.edi.document']._fields:
|
|
documents = self.filtered(lambda d: d.state in ('to_send', 'to_cancel') and d.blocking_level != 'error')
|
|
else:
|
|
documents = self.filtered(lambda d: d.state in ('to_send', 'to_cancel'))
|
|
for edi_doc in documents:
|
|
move = edi_doc.move_id
|
|
edi_format = edi_doc.edi_format_id
|
|
if move.is_invoice(include_receipts=True):
|
|
doc_type = 'invoice'
|
|
elif move.payment_id or move.statement_line_id:
|
|
doc_type = 'payment'
|
|
else:
|
|
continue
|
|
|
|
custom_key = edi_format._get_batch_key(edi_doc.move_id, edi_doc.state)
|
|
key = (edi_format, edi_doc.state, doc_type, move.company_id, custom_key)
|
|
to_process.setdefault(key, self.env['account.edi.document'])
|
|
to_process[key] |= edi_doc
|
|
|
|
# Order payments/invoice and create batches.
|
|
invoices = []
|
|
payments = []
|
|
for key, documents in to_process.items():
|
|
edi_format, state, doc_type, company_id, custom_key = key
|
|
target = invoices if doc_type == 'invoice' else payments
|
|
batch = self.env['account.edi.document']
|
|
for doc in documents:
|
|
if edi_format._support_batching(move=doc.move_id, state=state, company=company_id):
|
|
batch |= doc
|
|
else:
|
|
target.append((doc, doc_type))
|
|
if batch:
|
|
target.append((batch, doc_type))
|
|
return invoices + payments
|
|
|
|
@api.model
|
|
def _convert_to_old_jobs_format(self, jobs):
|
|
""" See '_prepare_jobs' :
|
|
Old format : ((edi_format, state, doc_type, company_id), documents)
|
|
Since edi_format, state and company_id can be deduced from documents, this is redundant and more prone to unexpected behaviours.
|
|
New format : (doc_type, documents).
|
|
|
|
However, for backward compatibility of 'process_jobs', we need a way to convert back to the old format.
|
|
"""
|
|
return [(
|
|
(documents.edi_format_id, documents[0].state, doc_type, documents.move_id.company_id),
|
|
documents
|
|
) for documents, doc_type in jobs]
|
|
|
|
@api.model
|
|
def _process_jobs(self, to_process):
|
|
""" Deprecated, use _process_job instead.
|
|
|
|
:param to_process: A list of tuples (key, documents)
|
|
* key: A tuple (edi_format_id, state, doc_type, company_id)
|
|
** edi_format_id: The format to perform the operation with
|
|
** state: The state of the documents of this job
|
|
** doc_type: Are the moves of this job invoice or payments ?
|
|
** company_id: The company the moves belong to
|
|
* documents: The documents related to this job. If edi_format_id does not support batch, length is one
|
|
"""
|
|
for key, documents in to_process:
|
|
dummy, dummy, doc_type, dummy = key
|
|
self._process_job(documents, doc_type)
|
|
|
|
@api.model
|
|
def _process_job(self, documents, doc_type):
|
|
"""Post or cancel move_id (invoice or payment) by calling the related methods on edi_format_id.
|
|
Invoices are processed before payments.
|
|
|
|
:param documents: The documents related to this job. If edi_format_id does not support batch, length is one
|
|
:param doc_type: Are the moves of this job invoice or payments ?
|
|
"""
|
|
def _postprocess_post_edi_results(documents, edi_result):
|
|
attachments_to_unlink = self.env['ir.attachment']
|
|
for document in documents:
|
|
move = document.move_id
|
|
move_result = edi_result.get(move, {})
|
|
if move_result.get('attachment'):
|
|
old_attachment = document.attachment_id
|
|
values = {
|
|
'attachment_id': move_result['attachment'].id,
|
|
'error': move_result.get('error', False),
|
|
'blocking_level': move_result.get('blocking_level', DEFAULT_BLOCKING_LEVEL) if 'error' in move_result else False,
|
|
}
|
|
if not values.get('error'):
|
|
values.update({'state': 'sent'})
|
|
document.write(values)
|
|
if not old_attachment.res_model or not old_attachment.res_id:
|
|
attachments_to_unlink |= old_attachment
|
|
else:
|
|
document.write({
|
|
'error': move_result.get('error', False),
|
|
'blocking_level': move_result.get('blocking_level', DEFAULT_BLOCKING_LEVEL) if 'error' in move_result else False,
|
|
})
|
|
|
|
# Attachments that are not explicitly linked to a business model could be removed because they are not
|
|
# supposed to have any traceability from the user.
|
|
attachments_to_unlink.unlink()
|
|
|
|
def _postprocess_cancel_edi_results(documents, edi_result):
|
|
invoice_ids_to_cancel = set() # Avoid duplicates
|
|
attachments_to_unlink = self.env['ir.attachment']
|
|
for document in documents:
|
|
move = document.move_id
|
|
move_result = edi_result.get(move, {})
|
|
if move_result.get('success') is True:
|
|
old_attachment = document.attachment_id
|
|
document.write({
|
|
'state': 'cancelled',
|
|
'error': False,
|
|
'attachment_id': False,
|
|
'blocking_level': False,
|
|
})
|
|
|
|
if move.is_invoice(include_receipts=True) and move.state == 'posted':
|
|
# The user requested a cancellation of the EDI and it has been approved. Then, the invoice
|
|
# can be safely cancelled.
|
|
invoice_ids_to_cancel.add(move.id)
|
|
|
|
if not old_attachment.res_model or not old_attachment.res_id:
|
|
attachments_to_unlink |= old_attachment
|
|
|
|
elif not move_result.get('success'):
|
|
document.write({
|
|
'error': move_result.get('error', False),
|
|
'blocking_level': move_result.get('blocking_level', DEFAULT_BLOCKING_LEVEL) if move_result.get('error') else False,
|
|
})
|
|
|
|
if invoice_ids_to_cancel:
|
|
invoices = self.env['account.move'].browse(list(invoice_ids_to_cancel))
|
|
invoices.button_draft()
|
|
invoices.button_cancel()
|
|
|
|
# Attachments that are not explicitly linked to a business model could be removed because they are not
|
|
# supposed to have any traceability from the user.
|
|
attachments_to_unlink.unlink()
|
|
|
|
test_mode = self._context.get('edi_test_mode', False)
|
|
|
|
documents.edi_format_id.ensure_one() # All account.edi.document of a job should have the same edi_format_id
|
|
documents.move_id.company_id.ensure_one() # All account.edi.document of a job should be from the same company
|
|
if len(set(doc.state for doc in documents)) != 1:
|
|
raise ValueError('All account.edi.document of a job should have the same state')
|
|
|
|
edi_format = documents.edi_format_id
|
|
state = documents[0].state
|
|
if doc_type == 'invoice':
|
|
if state == 'to_send':
|
|
edi_result = edi_format._post_invoice_edi(documents.move_id, test_mode=test_mode)
|
|
_postprocess_post_edi_results(documents, edi_result)
|
|
elif state == 'to_cancel':
|
|
edi_result = edi_format._cancel_invoice_edi(documents.move_id, test_mode=test_mode)
|
|
_postprocess_cancel_edi_results(documents, edi_result)
|
|
|
|
elif doc_type == 'payment':
|
|
if state == 'to_send':
|
|
edi_result = edi_format._post_payment_edi(documents.move_id, test_mode=test_mode)
|
|
_postprocess_post_edi_results(documents, edi_result)
|
|
elif state == 'to_cancel':
|
|
edi_result = edi_format._cancel_payment_edi(documents.move_id, test_mode=test_mode)
|
|
_postprocess_cancel_edi_results(documents, edi_result)
|
|
|
|
def _process_documents_no_web_services(self):
|
|
""" Post and cancel all the documents that don't need a web service.
|
|
"""
|
|
jobs = self.filtered(lambda d: not d.edi_format_id._needs_web_services())._prepare_jobs()
|
|
self._process_jobs(self._convert_to_old_jobs_format(jobs))
|
|
|
|
def _process_documents_web_services(self, job_count=None, with_commit=True):
|
|
""" Post and cancel all the documents that need a web service. This is called by CRON.
|
|
|
|
:param job_count: Limit to the number of jobs to process among the ones that are available for treatment.
|
|
"""
|
|
jobs = self.filtered(lambda d: d.edi_format_id._needs_web_services())._prepare_jobs()
|
|
jobs = jobs[0:job_count or len(jobs)]
|
|
for documents, doc_type in jobs:
|
|
move_to_cancel = documents.filtered(lambda doc: doc.attachment_id \
|
|
and doc.state == 'to_cancel' \
|
|
and doc.move_id.is_invoice(include_receipts=True) \
|
|
and doc.edi_format_id._is_required_for_invoice(doc.move_id)).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)])
|
|
# Locks the move that will be cancelled.
|
|
if move_to_cancel:
|
|
self._cr.execute('SELECT * FROM account_move WHERE id IN %s FOR UPDATE NOWAIT', [tuple(move_to_cancel.ids)])
|
|
|
|
# Locks the attachments that might be unlinked
|
|
if attachments_potential_unlink:
|
|
self._cr.execute('SELECT * FROM ir_attachment WHERE id IN %s FOR UPDATE NOWAIT', [tuple(attachments_potential_unlink.ids)])
|
|
|
|
self._process_job(documents, doc_type)
|
|
except OperationalError as e:
|
|
if e.pgcode == '55P03':
|
|
_logger.debug('Another transaction already locked documents rows. Cannot process documents.')
|
|
else:
|
|
raise e
|
|
else:
|
|
if with_commit and len(jobs) > 1:
|
|
self.env.cr.commit()
|