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"))