Skip to content

Commit 60ed203

Browse files
committed
[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).
1 parent 835f12e commit 60ed203

3 files changed

Lines changed: 107 additions & 4 deletions

File tree

purchase_ux/models/account_move.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,12 +95,14 @@ def action_purchase_matching(self):
9595
res["context"] = ctx
9696
# Show POLs with something pending, with a different criterion per document type:
9797
# * on a bill (in_invoice): ordered qty not billed yet (product_qty > qty_invoiced),
98-
# received or not. qty_to_invoice cannot be used here because on products
99-
# controlled on received quantities it is qty_received - qty_invoiced, so a
98+
# received or not, or qty pending to bill (qty_to_invoice > 0) for an over receipt,
99+
# fully ordered but not fully billed. qty_to_invoice cannot be used alone because on
100+
# products controlled on received quantities it is qty_received - qty_invoiced, so a
100101
# confirmed PO with no receipt yet gives 0 and the line would be hidden.
101102
# * on a credit note (in_refund): lines left with a pending refund by a return,
102103
# ie. billed more than received (qty_to_invoice < 0). product_qty cannot be used
103104
# here because it does not drop with a return, so a fully billed line gives 0.
105+
# POs with a forced invoice status are excluded: nothing left to bill on them.
104106
all_pols = self.env["purchase.order.line"].search(
105107
[
106108
("partner_id", "in", (self.partner_id | self.partner_id.commercial_partner_id).ids),
@@ -113,11 +115,14 @@ def action_purchase_matching(self):
113115
uom_precision = self.env["decimal.precision"].precision_get("Product Unit of Measure")
114116

115117
def _pending(pol):
116-
if pol.id in already_matched:
118+
if pol.id in already_matched or pol.order_id.force_invoiced_status:
117119
return False
118120
if is_refund:
119121
return float_compare(pol.qty_to_invoice, 0.0, precision_digits=uom_precision) < 0
120-
return float_compare(pol.product_qty, pol.qty_invoiced, precision_digits=uom_precision) > 0
122+
return (
123+
float_compare(pol.product_qty, pol.qty_invoiced, precision_digits=uom_precision) > 0
124+
or float_compare(pol.qty_to_invoice, 0.0, precision_digits=uom_precision) > 0
125+
)
121126

122127
pending_pol_ids = all_pols.filtered(_pending).ids
123128
domain = list(res.get("domain") or [])

purchase_ux/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@
33
# directory
44
##############################################################################
55

6+
from . import test_purchase_matching
67
from . import test_purchase_order
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
##############################################################################
2+
# For copyright and license notices, see __manifest__.py file in module root
3+
# directory
4+
##############################################################################
5+
from odoo import Command, fields
6+
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
7+
from odoo.tests import tagged
8+
9+
10+
@tagged("post_install", "-at_install")
11+
class TestPurchaseMatching(AccountTestInvoicingCommon):
12+
"""Lines offered by the purchase matching action, ordered/received/billed."""
13+
14+
@classmethod
15+
def setUpClass(cls):
16+
super().setUpClass()
17+
# forcing the invoice status of a PO is restricted to settings managers
18+
cls.env.user.groups_id |= cls.env.ref("base.group_system")
19+
cls.vendor = cls.env["res.partner"].create({"name": "Test Vendor Matching"})
20+
# a service controlled on received quantities lets us set qty_received by hand
21+
cls.product = cls.env["product.product"].create(
22+
{
23+
"name": "Test Service On Received",
24+
"type": "service",
25+
"purchase_method": "receive",
26+
}
27+
)
28+
29+
def _line(self, ordered, received=0.0, forced=False):
30+
purchase = self.env["purchase.order"].create(
31+
{
32+
"partner_id": self.vendor.id,
33+
"order_line": [
34+
Command.create({"product_id": self.product.id, "product_qty": ordered, "price_unit": 100.0})
35+
],
36+
}
37+
)
38+
purchase.button_confirm()
39+
purchase.order_line.qty_received = received
40+
purchase.force_invoiced_status = forced
41+
return purchase.order_line
42+
43+
def _bill(self, line, quantity, move_type="in_invoice"):
44+
self.env["account.move"].create(
45+
{
46+
"move_type": move_type,
47+
"partner_id": self.vendor.id,
48+
"invoice_date": fields.Date.today(),
49+
"invoice_line_ids": [
50+
Command.create(
51+
{
52+
"product_id": self.product.id,
53+
"quantity": quantity,
54+
"price_unit": 100.0,
55+
"purchase_line_id": line.id,
56+
"tax_ids": False,
57+
}
58+
)
59+
],
60+
}
61+
).action_post()
62+
63+
def _offered(self, move_type="in_invoice"):
64+
move = self.env["account.move"].create({"move_type": move_type, "partner_id": self.vendor.id})
65+
action = move.action_purchase_matching()
66+
self.env.flush_all() # the matching model is a SQL view read from the database
67+
return self.env["purchase.bill.line.match"].search(action["domain"]).pol_id
68+
69+
def test_bill_not_received(self):
70+
self.assertIn(self._line(100), self._offered())
71+
72+
def test_bill_partially_received(self):
73+
self.assertIn(self._line(100, received=60), self._offered())
74+
75+
def test_bill_over_receipt(self):
76+
line = self._line(40, received=41)
77+
self._bill(line, 40)
78+
self.assertIn(line, self._offered())
79+
80+
def test_bill_fully_billed(self):
81+
line = self._line(100, received=100)
82+
self._bill(line, 100)
83+
self.assertNotIn(line, self._offered())
84+
85+
def test_bill_forced_invoiced_status(self):
86+
self.assertNotIn(self._line(100, forced="invoiced"), self._offered())
87+
88+
def test_refund_pending_from_return(self):
89+
line = self._line(600, received=500)
90+
self._bill(line, 600)
91+
self.assertIn(line, self._offered(move_type="in_refund"))
92+
93+
def test_refund_already_credited(self):
94+
line = self._line(600, received=500)
95+
self._bill(line, 600)
96+
self._bill(line, 100, move_type="in_refund")
97+
self.assertNotIn(line, self._offered(move_type="in_refund"))

0 commit comments

Comments
 (0)