From 381d607d21aea441b4fc7082953baed6dac8aea2 Mon Sep 17 00:00:00 2001 From: mav-adhoc Date: Tue, 1 Sep 2026 13:34:04 +0000 Subject: [PATCH] [FIX] purchase_ux: complete the pending criterion in purchase matching The lines offered by the 'Match purchase lines' button still hide one case, and the button that closes an order for billing did not stick. * Over receipt on bills: when the vendor delivers more than ordered the line ends up fully ordered but not fully billed (ordered 40, received 41, billed 40), and the quantity left to bill was hidden. Add qty_to_invoice > 0 as a second term, the same criterion the native view uses. The first term is still needed for a confirmed purchase order with no receipt yet, where qty_to_invoice is 0 on products controlled on received quantities. * Orders set as 'Nothing to Bill': the matching view already excludes orders forced as 'No Bill to Receive', but not the other forced status, even though both mean the order is closed for billing. Exclude any forced invoice status. * Orders closed with the 'Set Invoiced' button: the button stamped invoice_status on the order and zeroed qty_to_invoice on the lines, both stored computed fields, so the next recompute undid it and neither the matcher nor the lines ever saw the order as closed for billing. Write force_invoiced_status instead, which is what everything else reads. Its domain in the form also never matched on 19.0, where purchase orders no longer have the 'done' state, so the button was unreachable: use locked. Add tests for the bill criterion (no receipt, partial receipt, over receipt, fully billed, forced status), for the credit note one merged in with the per document type criterion (pending refund from a return, return already credited) and for the button. --- purchase_ux/models/account_move.py | 13 ++- .../models/purchase_bill_line_match.py | 3 +- purchase_ux/models/purchase_order.py | 10 +- purchase_ux/tests/__init__.py | 1 + purchase_ux/tests/test_purchase_matching.py | 109 ++++++++++++++++++ purchase_ux/views/purchase_order_views.xml | 2 +- 6 files changed, 126 insertions(+), 12 deletions(-) create mode 100644 purchase_ux/tests/test_purchase_matching.py diff --git a/purchase_ux/models/account_move.py b/purchase_ux/models/account_move.py index eba6ceda..94af796e 100644 --- a/purchase_ux/models/account_move.py +++ b/purchase_ux/models/account_move.py @@ -103,9 +103,11 @@ def action_purchase_matching(self): ] # Show POLs with something pending, with a different criterion per document type: # * on a bill (in_invoice): ordered qty not billed yet (product_qty > qty_invoiced), - # received or not. qty_to_invoice cannot be used here because on products - # controlled on received quantities it is qty_received - qty_invoiced, so a - # confirmed PO with no receipt yet gives 0 and the line would be hidden. + # received or not, or qty still pending to bill (qty_to_invoice > 0), the only term + # that catches an over receipt, fully ordered but not fully billed. qty_to_invoice + # alone cannot be used because on products controlled on received quantities it is + # qty_received - qty_invoiced, so a confirmed PO with no receipt yet gives 0 and the + # line would be hidden. # * on a credit note (in_refund): lines left with a pending refund by a return, # ie. billed more than received (qty_to_invoice < 0). product_qty cannot be used # here because it does not drop with a return, so a fully billed line gives 0. @@ -125,7 +127,10 @@ def _pending(pol): return False if is_refund: return float_compare(pol.qty_to_invoice, 0.0, precision_digits=uom_precision) < 0 - return float_compare(pol.product_qty, pol.qty_invoiced, precision_digits=uom_precision) > 0 + return ( + float_compare(pol.product_qty, pol.qty_invoiced, precision_digits=uom_precision) > 0 + or float_compare(pol.qty_to_invoice, 0.0, precision_digits=uom_precision) > 0 + ) pending_pol_ids = all_pols.filtered(_pending).ids domain = list(res.get("domain") or []) diff --git a/purchase_ux/models/purchase_bill_line_match.py b/purchase_ux/models/purchase_bill_line_match.py index 3e395d5f..2f9dca58 100644 --- a/purchase_ux/models/purchase_bill_line_match.py +++ b/purchase_ux/models/purchase_bill_line_match.py @@ -39,12 +39,13 @@ def _compute_reference_description(self): @property def _table_query(self): + # any forced invoice status closes the order for billing, also 'no' (nothing to bill) return SQL( """ SELECT base.* FROM (%s) AS base WHERE base.purchase_order_id IS NULL OR base.purchase_order_id NOT IN ( - SELECT id FROM purchase_order WHERE force_invoiced_status = 'invoiced' + SELECT id FROM purchase_order WHERE force_invoiced_status IS NOT NULL ) """, super()._table_query, diff --git a/purchase_ux/models/purchase_order.py b/purchase_ux/models/purchase_order.py index b17ef14c..225d6f3b 100644 --- a/purchase_ux/models/purchase_order.py +++ b/purchase_ux/models/purchase_order.py @@ -50,12 +50,10 @@ def button_set_invoiced(self): ) else: raise UserError(_('Only users with "%s" can Set Invoiced manually') % (group.name)) - # In purchases the invoice status is not calculated from the lines, - # so we step on it in the PO. Do not step on the qty_invoiced because - # it seems more neat to restore what happened - - self.write({"invoice_status": "invoiced"}) - self.order_line.write({"qty_to_invoice": 0.0}) + # force_invoiced_status is the only value that survives a recompute: both + # invoice_status and qty_to_invoice are stored computed fields, so stamping + # them was undone by the next recompute of the order or its lines. + self.write({"force_invoiced_status": "invoiced"}) self.message_post(body=_("Manually setted as invoiced")) def write(self, vals): diff --git a/purchase_ux/tests/__init__.py b/purchase_ux/tests/__init__.py index ac6bb5cb..e98d797f 100644 --- a/purchase_ux/tests/__init__.py +++ b/purchase_ux/tests/__init__.py @@ -3,4 +3,5 @@ # directory ############################################################################## +from . import test_purchase_matching from . import test_purchase_order diff --git a/purchase_ux/tests/test_purchase_matching.py b/purchase_ux/tests/test_purchase_matching.py new file mode 100644 index 00000000..9a65caaa --- /dev/null +++ b/purchase_ux/tests/test_purchase_matching.py @@ -0,0 +1,109 @@ +############################################################################## +# For copyright and license notices, see __manifest__.py file in module root +# directory +############################################################################## +from odoo import Command, fields +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged + + +@tagged("post_install", "-at_install") +class TestPurchaseMatching(AccountTestInvoicingCommon): + """Lines offered by the purchase matching action, ordered/received/billed.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + # forcing the invoice status of a PO is restricted to settings managers + cls.env.user.group_ids |= cls.env.ref("base.group_system") + cls.vendor = cls.env["res.partner"].create({"name": "Test Vendor Matching"}) + # a service controlled on received quantities lets us set qty_received by hand + cls.product = cls.env["product.product"].create( + { + "name": "Test Service On Received", + "type": "service", + "purchase_method": "receive", + } + ) + + def _line(self, ordered, received=0.0, forced=False): + purchase = self.env["purchase.order"].create( + { + "partner_id": self.vendor.id, + "order_line": [ + Command.create({"product_id": self.product.id, "product_qty": ordered, "price_unit": 100.0}) + ], + } + ) + purchase.button_confirm() + purchase.order_line.qty_received = received + purchase.force_invoiced_status = forced + return purchase.order_line + + def _bill(self, line, quantity, move_type="in_invoice"): + self.env["account.move"].create( + { + "move_type": move_type, + "partner_id": self.vendor.id, + "invoice_date": fields.Date.today(), + "invoice_line_ids": [ + Command.create( + { + "product_id": self.product.id, + "quantity": quantity, + "price_unit": 100.0, + "purchase_line_id": line.id, + "tax_ids": False, + } + ) + ], + } + ).action_post() + + def _offered(self, move_type="in_invoice"): + move = self.env["account.move"].create({"move_type": move_type, "partner_id": self.vendor.id}) + action = move.action_purchase_matching() + self.env.flush_all() # the matching model is a SQL view read from the database + return self.env["purchase.bill.line.match"].search(action["domain"]).pol_id + + def test_bill_not_received(self): + self.assertIn(self._line(100), self._offered()) + + def test_bill_partially_received(self): + self.assertIn(self._line(100, received=60), self._offered()) + + def test_bill_over_receipt(self): + line = self._line(40, received=41) + self._bill(line, 40) + self.assertIn(line, self._offered()) + + def test_bill_fully_billed(self): + line = self._line(100, received=100) + self._bill(line, 100) + self.assertNotIn(line, self._offered()) + + def test_bill_forced_invoiced_status(self): + for forced in ("invoiced", "no"): + self.assertNotIn(self._line(100, forced=forced), self._offered()) + + def test_bill_set_invoiced_button(self): + """The Set Invoiced button closes the order for billing, and it sticks.""" + line = self._line(100, received=60) + line.order_id.button_set_invoiced() + self.assertEqual(line.order_id.force_invoiced_status, "invoiced") + self.assertEqual(line.order_id.invoice_status, "invoiced") + self.assertNotIn(line, self._offered()) + line.qty_received = 80 # a later recompute must not bring it back + self.assertEqual(line.order_id.invoice_status, "invoiced") + self.assertNotIn(line, self._offered()) + + def test_refund_pending_from_return(self): + line = self._line(600, received=500) + self._bill(line, 600) + self.assertIn(line, self._offered(move_type="in_refund")) + + def test_refund_already_credited(self): + line = self._line(600, received=500) + self._bill(line, 600) + self._bill(line, 100, move_type="in_refund") + self.assertNotIn(line, self._offered(move_type="in_refund")) diff --git a/purchase_ux/views/purchase_order_views.xml b/purchase_ux/views/purchase_order_views.xml index c62e844b..a07986b0 100644 --- a/purchase_ux/views/purchase_order_views.xml +++ b/purchase_ux/views/purchase_order_views.xml @@ -19,7 +19,7 @@