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 @@