[IMP] account/purchase: automatically match PO when importing bill

When a vendor bill document is uploaded (EDI, PDF,..) the link with
the purchase order is often lost.

We want to reuse the purchase.order OCR' matching logic to enhance
vendor bill extracted from documents.

To sum up the logic;

- if we find a partner or reference match (invoice_origin)
AND the same amount, we use autocomplete and replace the line in the vendor
bill with the purchase order one.

- if we find a match with the reference and some line in the purchase order
sum up to the vendor bill total, we add those line in the vendor bill
but we set the qty to zero (the accountant can  manually remove the XML
line and link the new one afterwards).

task-id: 2828521
[community](https://github.com/odoo/odoo/pull/109093)
[enterprise](https://github.com/odoo/enterprise/pull/35436)

update master: make _find_matching_subset_invoice_lines private

closes odoo/odoo#111491

X-original-commit: ebc8b007ecb375d20c566b6e6ecc3f6749ebaa2a
Related: odoo/enterprise#36545
Signed-off-by: Josse Colpaert <jco@odoo.com>
This commit is contained in:
det
2023-01-31 16:25:03 +01:00
parent 9d6607b329
commit 6a1d60bf54
6 changed files with 251 additions and 1 deletions
+4
View File
@@ -3353,6 +3353,10 @@ class AccountMove(models.Model):
return to_post
def _find_and_set_purchase_orders(self, po_references, partner_id, amount_total, prefer_purchase_line=False, timeout=10):
# hook to be used with purchase, so that vendor bills are sync/autocompleted with purchase orders
self.ensure_one()
# -------------------------------------------------------------------------
# PUBLIC ACTIONS
# -------------------------------------------------------------------------
@@ -81,3 +81,12 @@ class AccountJournal(models.Model):
protected_edi_formats = journal.edi_format_ids.filtered(lambda e: e.id in protected_edi_format_ids)
journal.edi_format_ids = enabled_edi_formats + protected_edi_formats
def _create_document_from_attachment(self, attachment_ids=None):
# tries to match purchasing orders
moves = super()._create_document_from_attachment(attachment_ids)
for move in moves:
if move.move_type == 'in_invoice':
references = [move.invoice_origin] if move.invoice_origin else []
move._find_and_set_purchase_orders(references, move.partner_id.id, move.amount_total, timeout=4)
return moves
@@ -246,6 +246,12 @@ class AccountEdiXmlCII(models.AbstractModel):
if ref_node is not None:
invoice.ref = ref_node.text
# ==== Invoice origin ====
invoice_origin_node = tree.find('./{*}OrderReference/{*}ID')
if invoice_origin_node is not None:
invoice.invoice_origin = invoice_origin_node.text
# === Note/narration ====
narration = ""
@@ -510,6 +510,12 @@ class AccountEdiXmlUBL20(models.AbstractModel):
else:
invoice.ref = ref_node.text
# ==== Invoice origin ====
invoice_origin_node = tree.find('./{*}OrderReference/{*}ID')
if invoice_origin_node is not None:
invoice.invoice_origin = invoice_origin_node.text
# === Note/narration ====
narration = ""
+121 -1
View File
@@ -1,7 +1,13 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import time
from odoo import api, fields, models, _
from odoo import api, fields, models, Command, _
_logger = logging.getLogger(__name__)
TOLERANCE = 0.02 # tolerance applied to the total when searching for a matching purchase order
class AccountMove(models.Model):
@@ -142,6 +148,120 @@ class AccountMove(models.Model):
move.message_post(body=message)
return res
def _find_matching_subset_invoice_lines(self, invoice_lines, goal_total, timeout):
""" The problem of finding the subset of `invoice_lines` which sums up to `goal_total` reduces to the 0-1 Knapsack problem.
The dynamic programming approach to solve this problem is most of the time slower than this because identical sub-problems don't arise often enough.
It returns the list of invoice lines which sums up to `goal_total` or an empty list if multiple or no solutions were found."""
def find_matching_subset_invoice_lines(lines, goal):
if time.time() - start_time > timeout:
raise TimeoutError
solutions = []
for i, line in enumerate(lines):
if line['amount_to_invoice'] < goal - TOLERANCE:
sub_solutions = find_matching_subset_invoice_lines(lines[i + 1:], goal - line['amount_to_invoice'])
solutions.extend((line, *solution) for solution in sub_solutions)
elif goal - TOLERANCE <= line['amount_to_invoice'] <= goal + TOLERANCE:
solutions.append([line])
if len(solutions) > 1:
# More than 1 solution found, we can't know for sure which is the correct one, so we don't return any solution
return []
return solutions
start_time = time.time()
try:
subsets = find_matching_subset_invoice_lines(sorted(invoice_lines, key=lambda line: line['amount_to_invoice'], reverse=True), goal_total)
return subsets[0] if subsets else []
except TimeoutError:
_logger.warning("Timed out during search of a matching subset of invoice lines")
return []
def _set_purchase_orders(self, purchase_orders, force_write=True):
with self.env.cr.savepoint():
with self._get_edi_creation() as move_form:
if force_write and move_form.line_ids:
move_form.invoice_line_ids = [Command.clear()]
for purchase_order in purchase_orders:
move_form.invoice_line_ids = [Command.create({
'display_type': 'line_section',
'name': _('From %s document', purchase_order.name)
})]
move_form.purchase_id = purchase_order
move_form._onchange_purchase_auto_complete()
def _match_purchase_orders(self, po_references, partner_id, amount_total, timeout):
""" Tries to match a purchase order given some bill arguments/hints.
:param po_references: A list of potencial purchase order references/name.
:param partner_id: The vendor id.
:param amount_total: The vendor bill total.
:param timeout: The timeout for subline search
:return: A tuple containing:
* a str which is the match method:
'total_match': the invoice amount AND the partner or bill' reference match
'subset_total_match': the reference AND a subset of line that match the bill amount total
'po_match': only the reference match
'no_match': no result found
* recordset of matched 'purchase.order.line' (could come from more than one purchase.order)
"""
common_domain = [('company_id', '=', self.company_id.id), ('state', '=', 'purchase'), ('invoice_status', 'in', ('to invoice', 'no'))]
matching_pos = self.env['purchase.order']
if po_references and amount_total:
matching_pos |= self.env['purchase.order'].search(common_domain + [('name', 'in', po_references)])
if not matching_pos:
matching_pos |= self.env['purchase.order'].search(common_domain + [('partner_ref', 'in', po_references)])
if matching_pos:
matching_pos_invoice_lines = [{
'line': line,
'amount_to_invoice': (1 - line.qty_invoiced / line.product_qty) * line.price_total,
} for line in matching_pos.order_line if line.product_qty]
if amount_total - TOLERANCE < sum(line['amount_to_invoice'] for line in matching_pos_invoice_lines) < amount_total + TOLERANCE:
return 'total_match', matching_pos.order_line
else:
il_subset = self._find_matching_subset_invoice_lines(matching_pos_invoice_lines, amount_total, timeout)
if il_subset:
return 'subset_total_match', self.env['purchase.order.line'].union(*[line['line'] for line in il_subset])
else:
return 'po_match', matching_pos.order_line
if partner_id and amount_total:
purchase_id_domain = common_domain + [('partner_id', 'child_of', [partner_id]), ('amount_total', '>=', amount_total - TOLERANCE), ('amount_total', '<=', amount_total + TOLERANCE)]
matching_pos |= self.env['purchase.order'].search(purchase_id_domain)
if len(matching_pos) == 1:
return 'total_match', matching_pos.order_line
return 'no_match', matching_pos.order_line
def _find_and_set_purchase_orders(self, po_references, partner_id, amount_total, prefer_purchase_line=False, timeout=10):
self.ensure_one()
method, matched_po_lines = self._match_purchase_orders(po_references, partner_id, amount_total, timeout)
if method == 'total_match': # erase all lines and autocomplete
self._set_purchase_orders(matched_po_lines.order_id, force_write=True)
elif method == 'subset_total_match': # don't erase and add autocomplete
self._set_purchase_orders(matched_po_lines.order_id, force_write=False)
with self._get_edi_creation() as move_form: # logic for unmatched lines
unmatched_lines = move_form.invoice_line_ids.filtered(
lambda l: l.purchase_line_id and l.purchase_line_id not in matched_po_lines)
for line in unmatched_lines:
if prefer_purchase_line:
line.quantity = 0
else:
line.unlink()
if not prefer_purchase_line:
move_form.invoice_line_ids.filtered('purchase_line_id').quantity = 0
elif method == 'po_match': # erase all lines and autocomplete
if prefer_purchase_line:
self._set_purchase_orders(matched_po_lines.order_id, force_write=True)
class AccountMoveLine(models.Model):
""" Override AccountInvoice_line to add the link to the purchase order line it is related to"""
@@ -1,5 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import timedelta
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.tests import tagged
from odoo.tests.common import Form
@@ -58,6 +60,34 @@ class TestPurchaseToInvoiceCommon(AccountTestInvoicingCommon):
'taxes_id': False,
})
@classmethod
def init_purchase(cls, partner=None, confirm=False, products=None, taxes=None, company=False):
date_planned = fields.Datetime.now() - timedelta(days=1)
po_form = Form(cls.env['purchase.order'] \
.with_company(company or cls.env.company) \
.with_context(tracking_disable=True))
po_form.partner_id = partner or cls.partner_a
po_form.partner_ref = 'my_match_reference'
for product in (products or []):
with po_form.order_line.new() as line_form:
line_form.product_id = product
line_form.price_unit = product.list_price
line_form.product_qty = 1
line_form.product_uom = product.uom_id
line_form.date_planned = date_planned
if taxes:
line_form.tax_ids.clear()
for tax in taxes:
line_form.tax_ids.add(tax)
rslt = po_form.save()
if confirm:
rslt.button_confirm()
return rslt
@tagged('post_install', '-at_install')
class TestPurchaseToInvoice(TestPurchaseToInvoiceCommon):
@@ -560,3 +590,78 @@ class TestPurchaseToInvoice(TestPurchaseToInvoiceCommon):
# Ensure price unit is updated when changing quantity on PO confirmed and line NOT linked to an invoice line
po_line.write({'product_qty': 4})
self.assertEqual(40.0, po_line.price_unit, "Unit price should be set to 40.0 for 4 quantity")
@tagged('post_install', '-at_install')
class TestInvoicePurchaseMatch(TestPurchaseToInvoiceCommon):
def test_total_match_via_partner(self):
po = self.init_purchase(confirm=True, partner=self.partner_a, products=[self.product_order])
invoice = self.init_invoice('in_invoice', partner=self.partner_a, products=[self.product_order])
invoice._find_and_set_purchase_orders(
[], invoice.partner_id.id, invoice.amount_total)
self.assertTrue(invoice.id in po.invoice_ids.ids)
self.assertEqual(invoice.amount_total, po.amount_total)
def test_total_match_via_po_reference(self):
po = self.init_purchase(confirm=True, products=[self.product_order])
invoice = self.init_invoice('in_invoice', partner=self.partner_a, products=[self.product_order])
invoice._find_and_set_purchase_orders(
['my_match_reference'], invoice.partner_id.id, invoice.amount_total)
self.assertTrue(invoice.id in po.invoice_ids.ids)
self.assertEqual(invoice.amount_total, po.amount_total)
def test_subset_total_match_prefer_purchase(self):
po = self.init_purchase(confirm=True, products=[self.product_order, self.service_order])
invoice = self.init_invoice('in_invoice', partner=self.partner_a, products=[self.product_order])
invoice._find_and_set_purchase_orders(
['my_match_reference'], invoice.partner_id.id, invoice.amount_total, prefer_purchase_line=True)
additional_unmatch_po_line = po.order_line.filtered(lambda l: l.product_id == self.service_order)
self.assertTrue(invoice.id in po.invoice_ids.ids)
self.assertTrue(additional_unmatch_po_line.id in invoice.line_ids.purchase_line_id.ids)
self.assertTrue(invoice.line_ids.filtered(lambda l: l.purchase_line_id == additional_unmatch_po_line).quantity == 0)
def test_subset_total_match_reject_purchase(self):
po = self.init_purchase(confirm=True, products=[self.product_order, self.service_order])
invoice = self.init_invoice('in_invoice', partner=self.partner_a, products=[self.product_order])
invoice._find_and_set_purchase_orders(
['my_match_reference'], invoice.partner_id.id, invoice.amount_total, prefer_purchase_line=False)
additional_unmatch_po_line = po.order_line.filtered(lambda l: l.product_id == self.service_order)
self.assertTrue(invoice.id in po.invoice_ids.ids)
self.assertTrue(additional_unmatch_po_line.id not in invoice.line_ids.purchase_line_id.ids)
def test_po_match_prefer_purchase(self):
po = self.init_purchase(confirm=True, products=[self.product_order, self.service_order])
invoice = self.init_invoice('in_invoice', products=[self.product_a])
invoice._find_and_set_purchase_orders(
['my_match_reference'], invoice.partner_id.id, invoice.amount_total, prefer_purchase_line=True)
self.assertTrue(invoice.id in po.invoice_ids.ids)
def test_po_match_reject_purchase(self):
po = self.init_purchase(confirm=True, products=[self.product_order, self.service_order])
invoice = self.init_invoice('in_invoice', products=[self.product_a])
invoice._find_and_set_purchase_orders(
['my_match_reference'], invoice.partner_id.id, invoice.amount_total, prefer_purchase_line=False)
self.assertTrue(invoice.id not in po.invoice_ids.ids)
self.assertNotEqual(invoice.amount_total, po.amount_total)
def test_no_match(self):
po = self.init_purchase(confirm=True, products=[self.product_order, self.service_order])
invoice = self.init_invoice('in_invoice', products=[self.product_a])
invoice._find_and_set_purchase_orders(
['other_reference'], invoice.partner_id.id, invoice.amount_total, prefer_purchase_line=False)
self.assertTrue(invoice.id not in po.invoice_ids.ids)