[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:
@@ -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 = ""
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user