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