summaryrefslogtreecommitdiff
path: root/addons/account_edi/models/account_edi_document.py
blob: afd58a07f7a6680a91bf2d4055a5bcad1fdcdfb1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
# -*- 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:
            edi_format, state, doc_type, company_id = 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_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():
                    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)])

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