Files
enterprise-addons/account_invoice_extract/models/account_invoice.py
T

917 lines
44 KiB
Python

import copy
import json
import logging
import re
from contextlib import contextmanager
from difflib import SequenceMatcher
from stdnum.eu.vat import guess_country
from psycopg2.errors import UniqueViolation
from odoo import api, fields, models, Command
from odoo.addons.iap.tools import iap_tools
from odoo.exceptions import AccessError, ConcurrencyError
from odoo.tools import _, float_compare
from odoo.tools.misc import clean_context, formatLang
_logger = logging.getLogger(__name__)
PARTNER_AUTOCOMPLETE_ENDPOINT = 'https://partner-autocomplete.odoo.com'
OCR_VERSION = 123
class AccountMove(models.Model):
_name = 'account.move'
_inherit = ['extract.mixin.with.words', '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(include_receipts=True)
@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(include_receipts=True) and record.company_id.extract_in_invoice_digitalization_mode != 'no_send') or
(record.is_sale_document(include_receipts=True) and record.company_id.extract_out_invoice_digitalization_mode != 'no_send')
)
)
extract_prefill_data = fields.Json()
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):
self = self.with_context(skip_is_manually_modified=True, from_ocr=True) # noqa: PLW0642
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(include_receipts=True):
move_form.ref = False
elif move_form.is_sale_document(include_receipts=True) 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()
except Exception as e:
_logger.warning("Error while reloading AI data on account.move %d: %s", self.id, e)
raise AccessError(self.env._("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().sudo().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):
""" 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 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(include_receipts=True)
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 is_indian_taxes(self):
l10n_in = self.env['ir.module.module'].sudo().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(include_receipts=True) 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(include_receipts=True):
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(include_receipts=True):
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
return text_to_send
@api.model
def _cron_validate(self):
validated = super()._cron_validate()
# We don't need prefill data anymore, we can delete them
validated.extract_prefill_data = False
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.with_context(skip_is_manually_modified=True)._validate_ocr()
return posted
@api.model
def _could_be_vat_number(self, text):
# Very naive, if at least half of the characters are digits, consider it could be a VAT number
n_digits = sum(c.isdigit() for c in text)
return 6 <= n_digits <= 15 and (n_digits / len(text)) >= 0.3
def get_partner_create_data(self, text):
default_values = self.extract_prefill_data
if self._could_be_vat_number(text):
default_values['vat'] = text
else:
default_values['name'] = text
if values := self._fetch_autocomplete_values(default_values.get('vat')):
default_values |= values
return {f'default_{k}': v for k, v in default_values.items()}
def get_partner_from_text(self, text):
self.ensure_one()
if not text:
return None
if self._could_be_vat_number(text):
partner_vat = self._find_partner_id_with_vat(text)
if partner_vat:
return partner_vat.id
partner = self._create_supplier_from_vat(text)
if partner:
self.partner_id = partner
return partner.id
partner = self._find_partner_id_with_name(text)
if partner:
return partner
return None
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):
rank_field = 'supplier_rank' if self.is_purchase_document(include_receipts=True) else 'customer_rank'
partner_vat = self.env["res.partner"].search([
*self.env['res.partner']._check_company_domain(self.company_id),
("vat", "=ilike", vat_number_ocr),
], order=f'{rank_field} desc', 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:]),
], order=f'{rank_field} desc', 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),
], order=f'{rank_field} desc', 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 _fetch_autocomplete_values(self, vat_number):
if not vat_number:
return None
try:
response, error = self.env['iap.autocomplete.api']._request_partner_autocomplete('enrich_by_vat', {
'vat': vat_number,
})
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 None
except Exception as exception:
_logger.error('Check VAT error: %s' % str(exception))
return None
if response and response.get('data'):
country_id = self.env['res.country'].search([('code', '=', response.get('data').get('country_code',''))])
resp_values = response.get('data')
values = {field: resp_values[field] for field in ('name', 'vat', 'street', 'city', 'zip', 'phone', 'email') if field in resp_values}
values['is_company'] = True
if country_id:
values['country_id'] = country_id.id
state_id = self.env['res.country.state'].search([
('name', '=', response.get('data').get('state_name','')),
('country_id', '=', country_id.id),
], limit = 1)
if state_id:
values['state_id'] = state_id.id
return values
return None
def _create_supplier_from_vat(self, vat_number_ocr):
values = self._fetch_autocomplete_values(vat_number_ocr)
if not values:
return False
for field, val in (self.extract_prefill_data or {}).items():
if field not in values:
values[field] = val
values['is_created_by_ocr'] = True
try:
return self.env["res.partner"].with_context(clean_context(self.env.context)).create(values)
except UniqueViolation:
# The partner has been created in another concurrent transaction.
# Use the retrying mechanism to see it.
raise ConcurrencyError("Duplicated OCR partner")
def _find_partner_id_with_name(self, partner_name):
if not partner_name:
return 0
rank_field = 'supplier_rank' if self.is_purchase_document(include_receipts=True) else 'customer_rank'
partner = self.env["res.partner"].search([
*self.env['res.partner']._check_company_domain(self.company_id),
("name", "=", partner_name),
], order=f'{rank_field} 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']._search([
*self.env['res.partner']._check_company_domain(self.company_id),
('active', '=', True),
('name', '!=', False),
(rank_field, '>', 0),
], bypass_access=True).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),
('sanitized_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(include_receipts=True) and self.extract_detected_layout:
partner = self._find_partner_from_previous_extracts()
if partner:
return partner, False
if self.is_purchase_document(include_receipts=True) 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(include_receipts=True) 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 = il.get('total', 0.0)
subtotal = il.get('subtotal', total)
taxes_ocr = il.get('taxes', [])
taxes_type_ocr = ['percent'] * len(il.get('taxes', []))
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 = il.get('description', "/")
total = il.get('total', 0.0)
subtotal = il.get('subtotal', total)
unit_price = il.get('unit_price', subtotal)
quantity = il.get('quantity', 1.0)
taxes_ocr = il.get('taxes', [])
taxes_type_ocr = ['percent'] * len(il.get('taxes', []))
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 _get_bank_account_vals(self, iban_ocr, SWIFT_code_ocr):
vals = {'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
return vals
def _fill_document_with_results(self, ocr_results):
self = self.with_context(skip_is_manually_modified=True, from_ocr=True) # noqa: PLW0642
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 self.move_type in ('in_invoice', 'out_invoice'):
# We only change the move type for invoices.
# We assume that if the user has specifically created a credit note/receipt, it is indeed a credit note/receipt.
detected_move_type = ocr_results.get('type')
if detected_move_type == 'receipt':
if self.move_type == 'in_invoice':
self.move_type = 'in_receipt'
elif self.move_type == 'out_invoice' and self.env['ir.config_parameter'].sudo().get_param('account.show_sale_receipts'):
self.move_type = 'out_receipt'
elif detected_move_type == 'refund':
self.action_switch_move_type()
def get_first_value_without(feature, not_allowed):
return next((
candidate['content']
for candidate in ocr_results.get(feature, {}).get('candidates', [])
if candidate['content'] not in not_allowed
), None)
country_code = self._get_ocr_selected_value(ocr_results, 'country', "")
iban_ocr = self._get_ocr_selected_value(ocr_results, 'iban', "")
SWIFT_code_ocr = json.loads(self._get_ocr_selected_value(ocr_results, 'SWIFT_code', "{}"))
self.extract_prefill_data = {
'country_id': self.env['res.country'].search([('code', '=', country_code)], limit=1).id,
'email': get_first_value_without('email', (self.company_id.email,)),
'website': get_first_value_without('website', (self.company_id.website,)),
'phone': get_first_value_without('phone', (self.company_id.phone,)),
'vat': next((
candidate['content']
for candidate in ocr_results.get('VAT_Number', {}).get('candidates', [])
if next(iter(guess_country(candidate['content'])), "").lower() == country_code.lower()
), next(iter(ocr_results.get('VAT_Number', {}).get('candidates', [])), {}).get('content')),
}
self.extract_prefill_data = {k: v for k, v in self.extract_prefill_data.items() if v is not None}
self._save_form(ocr_results)
self._autopost_bill()
def _save_form(self, ocr_results):
# Avoid marking is_manually_modified as True when posting an invoice
self = self.with_context(skip_is_manually_modified=True, from_ocr=True) # noqa: PLW0642
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(include_receipts=True) 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(include_receipts=True):
move_form.partner_bank_id = self.env['res.partner.bank']._find_or_create_bank_account(
account_number=iban_ocr,
partner=move_form.partner_id,
company=self.company_id,
extra_create_vals=self._get_bank_account_vals(iban_ocr, SWIFT_code_ocr),
)
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(include_receipts=True) else 0
if not move_form.partner_id:
partner_vals = {
'name': qr_content_list[5 + index_offset],
'is_company': True,
}
address_type = qr_content_list[4 + index_offset]
if address_type == 'S':
street = qr_content_list[6 + index_offset]
house_nb = qr_content_list[7 + index_offset]
partner_vals['street'] = f"{street} {house_nb}"
partner_vals['zip'] = qr_content_list[8 + index_offset]
partner_vals['city'] = qr_content_list[9 + index_offset]
elif address_type == 'K':
partner_vals['street'] = qr_content_list[6 + index_offset]
partner_vals['street2'] = qr_content_list[7 + index_offset]
country_code = qr_content_list[10 + index_offset]
if country_code:
country = self.env['res.country'].search([('code', '=', country_code)])
partner_vals['country_id'] = country and country.id
move_form.partner_id = self.env["res.partner"].with_context(clean_context(self.env.context)).create(partner_vals)
if self.is_purchase_document(include_receipts=True):
iban = qr_content_list[3]
if iban:
move_form.partner_bank_id = self.env['res.partner.bank']._find_or_create_bank_account(
account_number=iban,
partner=move_form.partner_id,
company=move_form.company_id,
extra_create_vals={
'company_id': move_form.company_id.id,
'currency_id': move_form.currency_id.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(include_receipts=True) and not move_form.ref:
move_form.ref = invoice_id_ocr
if self.is_sale_document(include_receipts=True) 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['total_amount_currency']
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['base_amount_currency']
tax_totals = self.tax_totals
if move_form.currency_id.is_zero(fixed_rounding_error) and tax_totals['has_tax_groups']:
max_tax_group = max(
[
tax_group
for subtotal in tax_totals['subtotals']
for tax_group in subtotal['tax_groups']
],
key=lambda tax_group: tax_group['tax_amount_currency'],
)
max_tax_group['tax_amount_currency'] += fixed_rounding_error
self.tax_totals = tax_totals
# Set the imported flag after taxes have been computed
move_form.invoice_line_ids.is_imported = True
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):
if invoice.invoice_line_ids:
return invoice._reason_cannot_decode_has_invoice_lines()
if not invoice._needs_auto_extract(new):
return invoice.env._("Automatic OCR does not apply to this document.")
with invoice.with_context(from_ocr=True)._get_edi_creation() as invoice:
invoice._message_set_main_attachment_id(file_data['attachment'], force=True, filter_xml=False)
invoice._send_batch_for_digitization()
def _get_import_file_type(self, file_data):
""" Set priority on PDF, JPG and PNG files (those are the formats supported by the OCR). """
# EXTENDS 'account'
if 'jpeg' in file_data['mimetype'] or file_data['name'].endswith(('.jpg', '.jpeg')):
return 'jpg'
if 'png' in file_data['mimetype'] or file_data['name'].endswith('.png'):
return 'png'
return super()._get_import_file_type(file_data)
@contextmanager
def _disable_discount_precision(self):
if self.env.context.get('from_ocr', False): # Don't disable discount precision if OCR is used
yield
else:
with super()._disable_discount_precision():
yield
def _get_edi_decoder(self, file_data, new=False):
# EXTENDS 'account'
if (
file_data['import_file_type'] in {'pdf', 'jpg', 'png'}
and file_data['attachment']
and self._check_digitalization_mode(self.company_id, self.move_type, 'auto_send')
):
return {
'decoder': self._import_invoice_ocr,
'priority': 10 if file_data['import_file_type'] == 'pdf' else 5,
}
return super()._get_edi_decoder(file_data, new)
@api.model
def _get_view(self, view_id=None, view_type='form', **options):
arch, view = super()._get_view(view_id, view_type, **options)
if view_type == 'form':
for node in arch.xpath("//field[@name='partner_id'][@widget='res_partner_many2one']"):
# Create a duplicate partner_id field with added placeholder text, which will be displayed instead
# of the default field when the OCR has been used and no partner was detected
node_with_placeholder = copy.deepcopy(node)
placeholder_condition = "(extract_state == 'waiting_validation' and not partner_id)"
if node.get("invisible"):
node.set('invisible', f"{placeholder_condition} or ({node.attrib.pop('invisible')})")
else:
node.set('invisible', placeholder_condition)
node_with_placeholder.set('invisible', f"not {node.get('invisible')}")
node_with_placeholder.set('placeholder', "Click here and select the vendor on the bill to create it")
# Mark the node as automatically added, like `_add_missing_fields` does in ir_ui_view,
# so that Studio does not consider it as a normal, user-editable node.
node_with_placeholder.set('data-used-by', 'account_invoice_extract')
node.addnext(node_with_placeholder)
return arch, view