From e8cbb3a7d0e4a8fdf94a284b088c4399a310d283 Mon Sep 17 00:00:00 2001 From: mav-adhoc Date: Wed, 12 Aug 2026 19:39:12 +0000 Subject: [PATCH] [FIX] purchase_ux: complete the pending criterion in purchase matching Two cases were still wrong in the lines offered by the 'Match purchase lines' button: * Over receipt: when the vendor delivers more than ordered, the line ends up fully ordered but not fully billed (ordered 40, received 41, billed 40). The bill criterion product_qty > qty_invoiced gives False 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. * Forced invoice status: a purchase order set as 'No Bill to Receive' / 'Nothing to Bill' kept offering its lines, because neither the filter nor the native view look at force_invoiced_status. Exclude them, on bills and on credit notes: the user already declared that order as nothing left to bill. Add tests for the bill criterion (no receipt, partial receipt, over receipt, fully billed, forced status) and for the credit note one (pending refund from a return, return already credited). * 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. X-original-commit: e67119e1f5b065fafbd0d5310bb17129cb453079 --- purchase_ux/models/account_move.py | 13 ++- purchase_ux/models/purchase_order.py | 20 ++++ purchase_ux/tests/__init__.py | 1 + purchase_ux/tests/test_purchase_matching.py | 108 ++++++++++++++++++++ 4 files changed, 138 insertions(+), 4 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..a19ff1fc 100644 --- a/purchase_ux/models/account_move.py +++ b/purchase_ux/models/account_move.py @@ -103,12 +103,14 @@ 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 + # received or not, or qty pending to bill (qty_to_invoice > 0) for an over receipt, + # fully ordered but not fully billed. qty_to_invoice cannot be used alone 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. + # POs with a forced invoice status are excluded: nothing left to bill on them. all_pols = self.env["purchase.order.line"].search( [ ("partner_id", "in", commercial_partner.ids), @@ -121,11 +123,14 @@ def action_purchase_matching(self): uom_precision = self.env["decimal.precision"].precision_get("Product Unit of Measure") def _pending(pol): - if pol.id in already_matched: + if pol.id in already_matched or pol.order_id.force_invoiced_status: 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_order.py b/purchase_ux/models/purchase_order.py index b17ef14c..9e9b6b22 100644 --- a/purchase_ux/models/purchase_order.py +++ b/purchase_ux/models/purchase_order.py @@ -44,6 +44,7 @@ def _get_invoiced(self): def button_set_invoiced(self): if not self.env.user.has_group("base.group_system"): group = self.env.ref("base.group_system").sudo() +<<<<<<< bed80522437e95ca8fdfb04e2f801b9beced261b if group.privilege_id: raise UserError( _('Only users with "%s / %s" can Set Invoiced manually') % (group.privilege_id.name, group.name) @@ -56,6 +57,25 @@ def button_set_invoiced(self): self.write({"invoice_status": "invoiced"}) self.order_line.write({"qty_to_invoice": 0.0}) +||||||| 44a1146a97c5cbcb5e7733eb02d6d7bf66bad909 + raise UserError( + _('Only users with "%s / %s" can Set Invoiced manually') % (group.category_id.name, 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}) +======= + raise UserError( + _('Only users with "%s / %s" can Set Invoiced manually') % (group.category_id.name, group.name) + ) + # 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"}) +>>>>>>> 869952226fa0e3a7a7ecdb217f1108ddbcf122e1 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..d9de090b --- /dev/null +++ b/purchase_ux/tests/test_purchase_matching.py @@ -0,0 +1,108 @@ +############################################################################## +# 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.groups_id |= 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): + self.assertNotIn(self._line(100, forced="invoiced"), 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"))