917 lines
44 KiB
Python
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
|