# -*- coding: utf-8 -*- from odoo import api, fields, models, _, _lt, Command from odoo.addons.iap.tools import iap_tools from odoo.exceptions import AccessError from odoo.tools import float_compare, mute_logger from odoo.tools.misc import clean_context, formatLang from difflib import SequenceMatcher import logging import re import json _logger = logging.getLogger(__name__) PARTNER_AUTOCOMPLETE_ENDPOINT = 'https://partner-autocomplete.odoo.com' OCR_VERSION = 122 class AccountInvoiceExtractionWords(models.Model): _name = "account.invoice_extract.words" _description = "Extracted words from invoice scan" invoice_id = fields.Many2one("account.move", required=True, ondelete='cascade', index=True, string="Invoice") field = fields.Char() ocr_selected = fields.Boolean() user_selected = fields.Boolean() word_text = fields.Char() word_page = fields.Integer() word_box_midX = fields.Float() word_box_midY = fields.Float() word_box_width = fields.Float() word_box_height = fields.Float() word_box_angle = fields.Float() class AccountMove(models.Model): _name = 'account.move' _inherit = ['extract.mixin', 'account.move'] @api.depends('state') def _compute_is_in_extractable_state(self): for record in self: record.is_in_extractable_state = record.state == 'draft' and record.is_invoice() @api.depends( 'state', 'extract_state', 'move_type', 'company_id.extract_in_invoice_digitalization_mode', 'company_id.extract_out_invoice_digitalization_mode', ) def _compute_show_banners(self): for record in self: record.extract_can_show_banners = ( record.state == 'draft' and ( (record.is_purchase_document() and record.company_id.extract_in_invoice_digitalization_mode != 'no_send') or (record.is_sale_document() and record.company_id.extract_out_invoice_digitalization_mode != 'no_send') ) ) extract_word_ids = fields.One2many("account.invoice_extract.words", inverse_name="invoice_id", copy=False) extract_attachment_id = fields.Many2one('ir.attachment', readonly=True, ondelete='set null', copy=False, index='btree_not_null') extract_can_show_banners = fields.Boolean("Can show the ocr banners", compute=_compute_show_banners) extract_detected_layout = fields.Integer("Extract Detected Layout Id", readonly=True) extract_partner_name = fields.Char("Extract Detected Partner Name", readonly=True) def action_reload_ai_data(self): try: with self._get_edi_creation() as move_form: # The OCR doesn't overwrite the fields, so it's necessary to reset them move_form.partner_id = False move_form.invoice_date = False move_form.invoice_payment_term_id = False move_form.invoice_date_due = False if move_form.is_purchase_document(): move_form.ref = False elif move_form.is_sale_document() and move_form.quick_edit_mode: move_form.name = False move_form.payment_reference = False move_form.currency_id = move_form.company_currency_id move_form.invoice_line_ids = [Command.clear()] self._check_ocr_status(force_write=True) except Exception as e: _logger.warning("Error while reloading AI data on account.move %d: %s", self.id, e) raise AccessError(_lt("Couldn't reload AI data.")) @api.model def _contact_iap_extract(self, pathinfo, params): params['version'] = OCR_VERSION params['account_token'] = self._get_iap_account().account_token endpoint = self.env['ir.config_parameter'].sudo().get_param('iap_extract_endpoint', 'https://extract.api.odoo.com') return iap_tools.iap_jsonrpc(endpoint + '/api/extract/invoice/2/' + pathinfo, params=params) @api.model def _contact_iap_partner_autocomplete(self, local_endpoint, params): return iap_tools.iap_jsonrpc(PARTNER_AUTOCOMPLETE_ENDPOINT + local_endpoint, params=params) def _check_digitalization_mode(self, company, document_type, mode): if document_type in self.get_purchase_types(): return company.extract_in_invoice_digitalization_mode == mode elif document_type in self.get_sale_types(): return company.extract_out_invoice_digitalization_mode == mode def _needs_auto_extract(self, new_document=False, file_type=''): """ Returns `True` if the document should be automatically sent to the extraction server""" self.ensure_one() # Check that the document meets the basic conditions for auto extraction if ( self.extract_state != "no_extract_requested" or not self._check_digitalization_mode(self.company_id, self.move_type, 'auto_send') or not self.is_in_extractable_state ): return False if self._context.get('from_alias'): # If the document comes from the email alias, check that the file format is compatible with the journal setting if not file_type and self.message_main_attachment_id: file_type = self.message_main_attachment_id.mimetype.split('/')[1] return ( not self.journal_id.alias_auto_extract_pdfs_only or file_type == 'pdf' ) elif new_document: # New documents are always auto extracted return True # If it's an existing document to which an attachment is added, only auto extract it for purchase documents return self.is_purchase_document() def _get_ocr_module_name(self): return 'account_invoice_extract' def _get_ocr_option_can_extract(self): self.ensure_one() return not self._check_digitalization_mode(self.company_id, self.move_type, 'no_send') def _get_validation_domain(self): base_domain = super()._get_validation_domain() return base_domain + [('state', '=', 'posted')] def _get_validation_fields(self): return [ 'total', 'subtotal', 'total_tax_amount', 'date', 'due_date', 'invoice_id', 'partner', 'VAT_Number', 'currency', 'payment_ref', 'iban', 'SWIFT_code', 'merged_lines', 'invoice_lines', ] def _get_user_error_invalid_state_message(self): return _("You cannot send a expense that is not in draft state!") def _upload_to_extract_success_callback(self): super()._upload_to_extract_success_callback() self.extract_attachment_id = self.message_main_attachment_id def is_indian_taxes(self): l10n_in = self.env['ir.module.module'].search([('name', '=', 'l10n_in')]) return self.company_id.country_id.code == "IN" and l10n_in and l10n_in.state == 'installed' def _get_user_infos(self): user_infos = super()._get_user_infos() user_infos.update({ 'user_company_VAT': self.company_id.vat, 'user_company_name': self.company_id.name, 'user_company_country_code': self.company_id.country_id.code, 'perspective': 'supplier' if self.is_sale_document() else 'client', }) return user_infos def _upload_to_extract(self): """ Call parent method _upload_to_extract only if self is an invoice. """ self.ensure_one() if self.is_invoice(): super()._upload_to_extract() def _get_validation(self, field): """ return the text or box corresponding to the choice of the user. If the user selected a box on the document, we return this box, but if he entered the text of the field manually, we return only the text, as we don't know which box is the right one (if it exists) """ text_to_send = {} if field == "total": text_to_send["content"] = self.amount_total elif field == "subtotal": text_to_send["content"] = self.amount_untaxed elif field == "total_tax_amount": text_to_send["content"] = self.amount_tax elif field == "date": text_to_send["content"] = str(self.invoice_date) if self.invoice_date else False elif field == "due_date": text_to_send["content"] = str(self.invoice_date_due) if self.invoice_date_due else False elif field == "invoice_id": if self.is_purchase_document(): text_to_send["content"] = self.ref else: text_to_send["content"] = self.name elif field == "partner": text_to_send["content"] = self.partner_id.name elif field == "VAT_Number": text_to_send["content"] = self.partner_id.vat elif field == "currency": text_to_send["content"] = self.currency_id.name elif field == "payment_ref": text_to_send["content"] = self.payment_reference elif field == "iban": text_to_send["content"] = self.partner_bank_id.acc_number if self.partner_bank_id else False elif field == "SWIFT_code": text_to_send["content"] = self.partner_bank_id.bank_bic if self.partner_bank_id else False elif field == 'merged_lines': return self.env.company.extract_single_line_per_tax elif field == "invoice_lines": text_to_send = {'lines': []} for il in self.invoice_line_ids: line = { "description": il.name, "quantity": il.quantity, "unit_price": il.price_unit, "product": il.product_id.id, "taxes_amount": round(il.price_total - il.price_subtotal, 2), "taxes": [{ 'amount': tax.amount, 'type': tax.amount_type, 'price_include': tax.price_include} for tax in il.tax_ids], "subtotal": il.price_subtotal, "total": il.price_total } text_to_send['lines'].append(line) if self.is_indian_taxes(): lines = text_to_send['lines'] for index, line in enumerate(text_to_send['lines']): for tax in line['taxes']: taxes = [] if tax['type'] == 'group': taxes.extend([{ 'amount': tax['amount'] / 2, 'type': 'percent', 'price_include': tax['price_include'] } for _ in range(2)]) else: taxes.append(tax) lines[index]['taxes'] = taxes text_to_send['lines'] = lines else: return None user_selected_box = self.env['account.invoice_extract.words'].search([ ('invoice_id', '=', self.id), ('field', '=', field), ('user_selected', '=', True), ('ocr_selected', '=', False), ]) if user_selected_box and user_selected_box.word_text == text_to_send['content']: text_to_send['box'] = [ user_selected_box.word_text, user_selected_box.word_page, user_selected_box.word_box_midX, user_selected_box.word_box_midY, user_selected_box.word_box_width, user_selected_box.word_box_height, user_selected_box.word_box_angle, ] return text_to_send @api.model def _cron_validate(self): validated = super()._cron_validate() validated.mapped('extract_word_ids').unlink() # We don't need word data anymore, we can delete them return validated def _post(self, soft=True): # OVERRIDE # On the validation of an invoice, send the different corrected fields to iap to improve the ocr algorithm. posted = super()._post(soft) self._validate_ocr() return posted def get_boxes(self): return [{ "id": data.id, "feature": data.field, "text": data.word_text, "ocr_selected": data.ocr_selected, "user_selected": data.user_selected, "page": data.word_page, "box_midX": data.word_box_midX, "box_midY": data.word_box_midY, "box_width": data.word_box_width, "box_height": data.word_box_height, "box_angle": data.word_box_angle} for data in self.extract_word_ids] def set_user_selected_box(self, id): """Set the selected box for a feature. The id of the box indicates the concerned feature. The method returns the text that can be set in the view (possibly different of the text in the file)""" self.ensure_one() word = self.env["account.invoice_extract.words"].browse(int(id)) to_unselect = self.env["account.invoice_extract.words"].search([("invoice_id", "=", self.id), ("field", "=", word.field), ("user_selected", "=", True)]) for box in to_unselect: box.user_selected = False word.user_selected = True if word.field == "currency": text = word.word_text currency = None currencies = self.env["res.currency"].search([]) for curr in currencies: if text == curr.currency_unit_label: currency = curr if text == curr.name or text == curr.symbol: currency = curr if currency: return currency.id return self.currency_id.id if word.field == "VAT_Number": partner_vat = False if word.word_text != "": partner_vat = self._find_partner_id_with_vat(word.word_text) if partner_vat: return partner_vat.id else: vat = word.word_text partner = self._create_supplier_from_vat(vat) return partner.id if partner else False if word.field == "supplier": return self._find_partner_id_with_name(word.word_text) return word.word_text def _find_partner_from_previous_extracts(self): """ Try to find the partner according to the detected layout. It is expected that two invoices emitted by the same supplier will share the same detected layout. """ match_conditions = [ ('extract_detected_layout', '=', self.extract_detected_layout), ('extract_partner_name', '=', self.extract_partner_name), ] for condition in match_conditions: invoice_layout = self.search([ condition, ('extract_state', '=', 'done'), ('move_type', '=', self.move_type), ('company_id', '=', self.company_id.id), ], limit=1000, order='id desc') if invoice_layout: break # Keep only if we have just one result if len(invoice_layout.mapped('partner_id')) == 1: return invoice_layout.partner_id return None def _find_partner_id_with_vat(self, vat_number_ocr): partner_vat = self.env["res.partner"].search([ *self.env['res.partner']._check_company_domain(self.company_id), ("vat", "=ilike", vat_number_ocr), ], limit=1) if not partner_vat: partner_vat = self.env["res.partner"].search([ *self.env['res.partner']._check_company_domain(self.company_id), ("vat", "=ilike", vat_number_ocr[2:]), ], limit=1) if not partner_vat: for partner in self.env["res.partner"].search([ *self.env['res.partner']._check_company_domain(self.company_id), ("vat", "!=", False), ], limit=1000): vat = partner.vat.upper() vat_cleaned = vat.replace("BTW", "").replace("MWST", "").replace("ABN", "") vat_cleaned = re.sub(r'[^A-Z0-9]', '', vat_cleaned) if vat_cleaned == vat_number_ocr or vat_cleaned == vat_number_ocr[2:]: partner_vat = partner break return partner_vat def _create_supplier_from_vat(self, vat_number_ocr): try: response, error = self.env['iap.autocomplete.api']._request_partner_autocomplete( action='enrich', params={'vat': vat_number_ocr}, ) if error: raise Exception(error) if 'credit_error' in response and response['credit_error']: _logger.warning("Credit error on partner_autocomplete call") except KeyError: _logger.warning("Partner autocomplete isn't installed, supplier creation from VAT is disabled") return False except Exception as exception: _logger.error('Check VAT error: %s' % str(exception)) return False if response and response.get('company_data'): country_id = self.env['res.country'].search([('code', '=', response.get('company_data').get('country_code',''))]) state_id = self.env['res.country.state'].search([('name', '=', response.get('company_data').get('state_name',''))]) resp_values = response.get('company_data') values = {field: resp_values[field] for field in ('name', 'vat', 'street', 'city', 'zip', 'phone', 'email', 'partner_gid') if field in resp_values} values['is_company'] = True if 'bank_ids' in resp_values: values['bank_ids'] = [(0, 0, vals) for vals in resp_values['bank_ids']] if country_id: values['country_id'] = country_id.id if state_id: values['state_id'] = state_id.id new_partner = self.env["res.partner"].with_context(clean_context(self.env.context)).create(values) return new_partner return False def _find_partner_id_with_name(self, partner_name): if not partner_name: return 0 partner = self.env["res.partner"].search([ *self.env['res.partner']._check_company_domain(self.company_id), ("name", "=", partner_name), ], order='supplier_rank desc', limit=1) if partner: return partner.id if partner.id != self.company_id.partner_id.id else 0 self.env.cr.execute(*self.env['res.partner']._where_calc([ *self.env['res.partner']._check_company_domain(self.company_id), ('active', '=', True), ('name', '!=', False), ('supplier_rank', '>', 0), ]).select('res_partner.id', 'res_partner.name')) partners_dict = {name.lower().replace('-', ' '): partner_id for partner_id, name in self.env.cr.fetchall()} partner_name = partner_name.lower().strip() partners = {} for single_word in [word for word in re.findall(r"\w+", partner_name) if len(word) >= 3]: partners_matched = [partner for partner in partners_dict if single_word in partner.split()] for partner in partners_matched: # Record only if the whole sequence is a very close match if SequenceMatcher(None, partner.lower(), partner_name.lower()).ratio() > 0.8: partners[partner] = partners[partner] + 1 if partner in partners else 1 if partners: sorted_partners = sorted(partners, key=partners.get, reverse=True) if len(sorted_partners) == 1 or partners[sorted_partners[0]] != partners[sorted_partners[1]]: partner = sorted_partners[0] if partners_dict[partner] != self.company_id.partner_id.id: return partners_dict[partner] return 0 def _find_partner_with_iban(self, iban_ocr, partner_name): bank_accounts = self.env['res.partner.bank'].search([ *self.env['res.partner.bank']._check_company_domain(self.company_id), ('acc_number', '=ilike', iban_ocr), ]) bank_account_match_ratios = sorted([ (account, SequenceMatcher(None, partner_name.lower(), account.partner_id.name.lower()).ratio()) for account in bank_accounts ], key=lambda x: x[1], reverse=True) # Take the partner with the closest name match. # The IBAN should be safe enough to avoid false positives, but better safe than sorry. if bank_account_match_ratios and bank_account_match_ratios[0][1] > 0.3: return bank_account_match_ratios[0][0].partner_id return None def _get_partner(self, ocr_results): vat_number_ocr = self._get_ocr_selected_value(ocr_results, 'VAT_Number', "") iban_ocr = self._get_ocr_selected_value(ocr_results, 'iban', "") if vat_number_ocr: partner_vat = self._find_partner_id_with_vat(vat_number_ocr) if partner_vat: return partner_vat, False if self.is_purchase_document() and self.extract_detected_layout: partner = self._find_partner_from_previous_extracts() if partner: return partner, False if self.is_purchase_document() and iban_ocr: partner = self._find_partner_with_iban(iban_ocr, self.extract_partner_name) if partner: return partner, False partner_id = self._find_partner_id_with_name(self.extract_partner_name) if partner_id != 0: return self.env["res.partner"].browse(partner_id), False # Create a partner from the VAT number if vat_number_ocr: created_supplier = self._create_supplier_from_vat(vat_number_ocr) if created_supplier: return created_supplier, True return False, False def _get_taxes_record(self, taxes_ocr, taxes_type_ocr): """ Find taxes records to use from the taxes detected for an invoice line. """ taxes_found = self.env['account.tax'] type_tax_use = 'purchase' if self.is_purchase_document() else 'sale' if self.is_indian_taxes() and len(taxes_ocr) > 1: total_tax = sum(taxes_ocr) grouped_taxes_records = self.env['account.tax'].search([ *self.env['account.tax']._check_company_domain(self.company_id), ('amount', '=', total_tax), ('amount_type', '=', 'group'), ('type_tax_use', '=', type_tax_use), ]) for grouped_tax in grouped_taxes_records: children_taxes = grouped_tax.children_tax_ids.mapped('amount') if set(taxes_ocr) == set(children_taxes): return grouped_tax for (taxes, taxes_type) in zip(taxes_ocr, taxes_type_ocr): if taxes != 0.0: related_documents = self.env['account.move'].search([ ('state', '!=', 'draft'), ('move_type', '=', self.move_type), ('partner_id', '=', self.partner_id.id), ('company_id', '=', self.company_id.id), ], limit=100, order='id desc') lines = related_documents.mapped('invoice_line_ids') taxes_ids = related_documents.mapped('invoice_line_ids.tax_ids') taxes_ids = taxes_ids.filtered( lambda tax: tax.active and tax.amount == taxes and tax.amount_type == taxes_type and tax.type_tax_use == type_tax_use ) taxes_by_document = [] for tax in taxes_ids: taxes_by_document.append((tax, lines.filtered(lambda line: tax in line.tax_ids))) if len(taxes_by_document) != 0: taxes_found |= max(taxes_by_document, key=lambda tax: len(tax[1]))[0] else: tax_domain = [ *self.env['account.tax']._check_company_domain(self.company_id), ('amount', '=', taxes), ('amount_type', '=', taxes_type), ('type_tax_use', '=', type_tax_use), ] default_taxes = self.journal_id.default_account_id.tax_ids matching_default_tax = default_taxes.filtered_domain(tax_domain) if matching_default_tax: taxes_found |= matching_default_tax else: taxes_records = self.env['account.tax'].search(tax_domain) if taxes_records: taxes_records_setting_based = taxes_records.filtered(lambda r: not r.price_include) if taxes_records_setting_based: taxes_record = taxes_records_setting_based[0] else: taxes_record = taxes_records[0] taxes_found |= taxes_record return taxes_found def _get_currency(self, currency_ocr, partner_id): for comparison in ['=ilike', 'ilike']: possible_currencies = self.env["res.currency"].search([ '|', '|', ('currency_unit_label', comparison, currency_ocr), ('name', comparison, currency_ocr), ('symbol', comparison, currency_ocr), ]) if possible_currencies: break partner_last_invoice_currency = partner_id.invoice_ids[:1].currency_id if partner_last_invoice_currency in possible_currencies: return partner_last_invoice_currency if self.company_id.currency_id in possible_currencies: return self.company_id.currency_id return possible_currencies if len(possible_currencies) == 1 else None def _get_invoice_lines(self, ocr_results): """ Get write values for invoice lines. """ self.ensure_one() invoice_lines = ocr_results.get('invoice_lines', []) subtotal_ocr = self._get_ocr_selected_value(ocr_results, 'subtotal', 0.0) supplier_ocr = self._get_ocr_selected_value(ocr_results, 'supplier', "") date_ocr = self._get_ocr_selected_value(ocr_results, 'date', "") invoice_lines_to_create = [] if self.company_id.extract_single_line_per_tax: merged_lines = {} for il in invoice_lines: total = self._get_ocr_selected_value(il, 'total', 0.0) subtotal = self._get_ocr_selected_value(il, 'subtotal', total) taxes_ocr = [value['content'] for value in il.get('taxes', {}).get('selected_values', [])] taxes_type_ocr = [value.get('amount_type', 'percent') for value in il.get('taxes', {}).get('selected_values', [])] taxes_records = self._get_taxes_record(taxes_ocr, taxes_type_ocr) if not taxes_records and taxes_ocr: taxes_ids = ('not found', *sorted(taxes_ocr)) else: taxes_ids = ('found', *sorted(taxes_records.ids)) if taxes_ids not in merged_lines: merged_lines[taxes_ids] = {'subtotal': subtotal} else: merged_lines[taxes_ids]['subtotal'] += subtotal merged_lines[taxes_ids]['taxes_records'] = taxes_records # if there is only one line after aggregating the lines, use the total found by the ocr as it is less error-prone if len(merged_lines) == 1: merged_lines[list(merged_lines.keys())[0]]['subtotal'] = subtotal_ocr description_fields = [] if supplier_ocr: description_fields.append(supplier_ocr) if date_ocr: description_fields.append(date_ocr.split()[0]) description = ' - '.join(description_fields) for il in merged_lines.values(): vals = { 'name': description, 'price_unit': il['subtotal'], 'quantity': 1.0, 'tax_ids': il['taxes_records'], } invoice_lines_to_create.append(vals) else: for il in invoice_lines: description = self._get_ocr_selected_value(il, 'description', "/") total = self._get_ocr_selected_value(il, 'total', 0.0) subtotal = self._get_ocr_selected_value(il, 'subtotal', total) unit_price = self._get_ocr_selected_value(il, 'unit_price', subtotal) quantity = self._get_ocr_selected_value(il, 'quantity', 1.0) taxes_ocr = [value['content'] for value in il.get('taxes', {}).get('selected_values', [])] taxes_type_ocr = [value.get('amount_type', 'percent') for value in il.get('taxes', {}).get('selected_values', [])] vals = { 'name': description, 'price_unit': unit_price, 'quantity': quantity, 'tax_ids': self._get_taxes_record(taxes_ocr, taxes_type_ocr) } invoice_lines_to_create.append(vals) return invoice_lines_to_create def _fill_document_with_results(self, ocr_results, force_write=False): if self.state != 'draft' or ocr_results is None: return if 'detected_layout_id' in ocr_results: self.extract_detected_layout = ocr_results['detected_layout_id'] if ocr_results.get('type') == 'refund' and self.move_type in ('in_invoice', 'out_invoice'): # We only switch from an invoice to a credit note, not the other way around. # We assume that if the user has specifically created a credit note, it is indeed a credit note. self.action_switch_move_type() self._save_form(ocr_results, force_write=force_write) if self.extract_word_ids: # We don't want to recreate the boxes when the user clicks on "Reload AI data" return fields_with_boxes = ['supplier', 'date', 'due_date', 'invoice_id', 'currency', 'VAT_Number', 'total'] for field in filter(ocr_results.get, fields_with_boxes): value = ocr_results[field] selected_value = value.get('selected_value') data = [] # We need to make sure that only one candidate is selected. # Once this flag is set, the next candidates can't be set as selected. ocr_chosen_candidate_found = False for candidate in value.get('candidates', []): ocr_chosen = selected_value == candidate and not ocr_chosen_candidate_found if ocr_chosen: ocr_chosen_candidate_found = True data.append((0, 0, { "field": field, "ocr_selected": ocr_chosen, "user_selected": ocr_chosen, "word_text": candidate['content'], "word_page": candidate['page'], "word_box_midX": candidate['coords'][0], "word_box_midY": candidate['coords'][1], "word_box_width": candidate['coords'][2], "word_box_height": candidate['coords'][3], "word_box_angle": candidate['coords'][4], })) self.write({'extract_word_ids': data}) def _save_form(self, ocr_results, force_write=False): date_ocr = self._get_ocr_selected_value(ocr_results, 'date', "") due_date_ocr = self._get_ocr_selected_value(ocr_results, 'due_date', "") total_ocr = self._get_ocr_selected_value(ocr_results, 'total', 0.0) invoice_id_ocr = self._get_ocr_selected_value(ocr_results, 'invoice_id', "") currency_ocr = self._get_ocr_selected_value(ocr_results, 'currency', "") payment_ref_ocr = self._get_ocr_selected_value(ocr_results, 'payment_ref', "") iban_ocr = self._get_ocr_selected_value(ocr_results, 'iban', "") SWIFT_code_ocr = json.loads(self._get_ocr_selected_value(ocr_results, 'SWIFT_code', "{}")) or None qr_bill_ocr = self._get_ocr_selected_value(ocr_results, 'qr-bill') supplier_ocr = self._get_ocr_selected_value(ocr_results, 'supplier', "") client_ocr = self._get_ocr_selected_value(ocr_results, 'client', "") total_tax_amount_ocr = self._get_ocr_selected_value(ocr_results, 'total_tax_amount', 0.0) self.extract_partner_name = client_ocr if self.is_sale_document() else supplier_ocr with self._get_edi_creation() as move_form: if not move_form.partner_id: partner_id, created = self._get_partner(ocr_results) if partner_id: move_form.partner_id = partner_id if created and iban_ocr and not move_form.partner_bank_id and self.is_purchase_document(): bank_account = self.env['res.partner.bank'].search([ *self.env['res.partner.bank']._check_company_domain(self.company_id), ('acc_number', '=ilike', iban_ocr), ]) if bank_account: if bank_account.partner_id == move_form.partner_id.id: move_form.partner_bank_id = bank_account else: vals = { 'partner_id': move_form.partner_id.id, 'acc_number': iban_ocr } if SWIFT_code_ocr: bank_id = self.env['res.bank'].search([('bic', '=', SWIFT_code_ocr['bic'])], limit=1) if bank_id: vals['bank_id'] = bank_id.id if not bank_id and SWIFT_code_ocr['verified_bic']: country_id = self.env['res.country'].search([('code', '=', SWIFT_code_ocr['country_code'])], limit=1) if country_id: vals['bank_id'] = self.env['res.bank'].create({'name': SWIFT_code_ocr['name'], 'country': country_id.id, 'city': SWIFT_code_ocr['city'], 'bic': SWIFT_code_ocr['bic']}).id move_form.partner_bank_id = self.with_context(clean_context(self.env.context)).env['res.partner.bank'].create(vals) if qr_bill_ocr: qr_content_list = qr_bill_ocr.splitlines() # Supplier and client sections have an offset of 16 index_offset = 16 if self.is_sale_document() else 0 if not move_form.partner_id: partner_name = qr_content_list[5 + index_offset] move_form.partner_id = self.env["res.partner"].with_context(clean_context(self.env.context)).create({ 'name': partner_name, 'is_company': True, }) partner = move_form.partner_id address_type = qr_content_list[4 + index_offset] if address_type == 'S': if not partner.street: street = qr_content_list[6 + index_offset] house_nb = qr_content_list[7 + index_offset] partner.street = " ".join((street, house_nb)) if not partner.zip: partner.zip = qr_content_list[8 + index_offset] if not partner.city: partner.city = qr_content_list[9 + index_offset] elif address_type == 'K': if not partner.street: partner.street = qr_content_list[6 + index_offset] partner.street2 = qr_content_list[7 + index_offset] country_code = qr_content_list[10 + index_offset] if not partner.country_id and country_code: country = self.env['res.country'].search([('code', '=', country_code)]) partner.country_id = country and country.id if self.is_purchase_document(): iban = qr_content_list[3] if iban and not self.env['res.partner.bank'].search([('acc_number', '=ilike', iban)]): move_form.partner_bank_id = self.with_context(clean_context(self.env.context)).env['res.partner.bank'].create({ 'acc_number': iban, 'company_id': move_form.company_id.id, 'currency_id': move_form.currency_id.id, 'partner_id': partner.id, }) due_date_move_form = move_form.invoice_date_due # remember the due_date, as it could be modified by the onchange() of invoice_date context_create_date = fields.Date.context_today(self, self.create_date) if date_ocr and (not move_form.invoice_date or move_form.invoice_date == context_create_date): move_form.invoice_date = date_ocr if due_date_ocr and due_date_move_form == context_create_date: if date_ocr == due_date_ocr and move_form.partner_id and move_form.partner_id.property_supplier_payment_term_id: # if the invoice date and the due date found by the OCR are the same, we use the payment terms of the detected supplier instead, if there is one move_form.invoice_payment_term_id = move_form.partner_id.property_supplier_payment_term_id else: move_form.invoice_date_due = due_date_ocr if self.is_purchase_document() and not move_form.ref: move_form.ref = invoice_id_ocr if self.is_sale_document() and self.quick_edit_mode: move_form.name = invoice_id_ocr if payment_ref_ocr and not move_form.payment_reference: move_form.payment_reference = payment_ref_ocr add_lines = not move_form.invoice_line_ids if add_lines: if currency_ocr and move_form.currency_id == move_form.company_currency_id: currency = self._get_currency(currency_ocr, move_form.partner_id) if currency: move_form.currency_id = currency vals_invoice_lines = self._get_invoice_lines(ocr_results) # Create the lines with only the name for account_predictive_bills move_form.invoice_line_ids = [ Command.create({'name': line_vals.pop('name')}) for line_vals in vals_invoice_lines ] if add_lines: # We needed to close the first _get_edi_creation context to let account_predictive_bills do the predictions based on the label with self._get_edi_creation() as move_form: # Now edit them with the correct amount and apply the taxes for line, ocr_line_vals in zip(move_form.invoice_line_ids[-len(vals_invoice_lines):], vals_invoice_lines): line.write({ 'price_unit': ocr_line_vals['price_unit'], 'quantity': ocr_line_vals['quantity'], }) taxes_dict = {} for tax in line.tax_ids: taxes_dict[(tax.amount, tax.amount_type, tax.price_include)] = { 'found_by_OCR': False, 'tax_record': tax, } for taxes_record in ocr_line_vals['tax_ids']: tax_tuple = (taxes_record.amount, taxes_record.amount_type, taxes_record.price_include) if tax_tuple not in taxes_dict: line.tax_ids = [Command.link(taxes_record.id)] else: taxes_dict[tax_tuple]['found_by_OCR'] = True if taxes_record.price_include: line.price_unit *= 1 + taxes_record.amount / 100 for tax_info in taxes_dict.values(): if not tax_info['found_by_OCR']: amount_before = line.price_total line.tax_ids = [Command.unlink(tax_info['tax_record'].id)] # If the total amount didn't change after removing it, we can actually leave it. # This is intended as a way to keep intra-community taxes if line.price_total == amount_before: line.tax_ids = [Command.link(tax_info['tax_record'].id)] # Check the tax roundings after the tax lines have been synced tax_amount_rounding_error = total_ocr - self.tax_totals['amount_total'] threshold = len(vals_invoice_lines) * move_form.currency_id.rounding # Check if tax amounts detected by the ocr are correct and # replace the taxes that caused the rounding error in case of indian localization if not move_form.currency_id.is_zero(tax_amount_rounding_error) and self.is_indian_taxes(): fixed_rounding_error = total_ocr - total_tax_amount_ocr - self.tax_totals['amount_untaxed'] tax_totals = self.tax_totals tax_groups = tax_totals['groups_by_subtotal']['Untaxed Amount'] if move_form.currency_id.is_zero(fixed_rounding_error) and tax_groups: tax = total_tax_amount_ocr / len(tax_groups) for tax_total in tax_groups: tax_total.update({ 'tax_group_amount': tax, 'formatted_tax_group_amount': formatLang(self.env, tax, currency_obj=self.currency_id), }) self.tax_totals = tax_totals if ( not move_form.currency_id.is_zero(tax_amount_rounding_error) and float_compare(abs(tax_amount_rounding_error), threshold, precision_digits=2) <= 0 ): self._check_total_amount(total_ocr) # ------------------------------------------------------------------------- # EDI # ------------------------------------------------------------------------- @api.model def _import_invoice_ocr(self, invoice, file_data, new=False): with invoice._get_edi_creation() as invoice: invoice.message_main_attachment_id = file_data['attachment'] invoice._send_batch_for_digitization() return True def _get_edi_decoder(self, file_data, new=False): # EXTENDS 'account' self.ensure_one() if file_data['type'] in ('pdf', 'binary') and self._needs_auto_extract(new_document=new, file_type=file_data['type']): return self._import_invoice_ocr return super()._get_edi_decoder(file_data, new=new)