diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index 94b64974c53..20c56f258e0 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -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 # ------------------------------------------------------------------------- diff --git a/addons/account_edi/models/account_journal.py b/addons/account_edi/models/account_journal.py index be5d975d79f..862e3d8a8ed 100644 --- a/addons/account_edi/models/account_journal.py +++ b/addons/account_edi/models/account_journal.py @@ -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 diff --git a/addons/account_edi_ubl_cii/models/account_edi_xml_cii_facturx.py b/addons/account_edi_ubl_cii/models/account_edi_xml_cii_facturx.py index 835c1083afc..5ce21f5f3bf 100644 --- a/addons/account_edi_ubl_cii/models/account_edi_xml_cii_facturx.py +++ b/addons/account_edi_ubl_cii/models/account_edi_xml_cii_facturx.py @@ -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 = "" diff --git a/addons/account_edi_ubl_cii/models/account_edi_xml_ubl_20.py b/addons/account_edi_ubl_cii/models/account_edi_xml_ubl_20.py index 979c2b6a8d8..afbb6def71d 100644 --- a/addons/account_edi_ubl_cii/models/account_edi_xml_ubl_20.py +++ b/addons/account_edi_ubl_cii/models/account_edi_xml_ubl_20.py @@ -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 = "" diff --git a/addons/purchase/models/account_invoice.py b/addons/purchase/models/account_invoice.py index b72d1a2711b..b1b6441e7df 100644 --- a/addons/purchase/models/account_invoice.py +++ b/addons/purchase/models/account_invoice.py @@ -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""" diff --git a/addons/purchase/tests/test_purchase_invoice.py b/addons/purchase/tests/test_purchase_invoice.py index 99832f507ca..962fe271b27 100644 --- a/addons/purchase/tests/test_purchase_invoice.py +++ b/addons/purchase/tests/test_purchase_invoice.py @@ -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)