Skip to content

Commit c4b6f1e

Browse files
committed
[FIX] purchase_ux: complete the pending criterion in purchase matching
The lines offered by the 'Match purchase lines' button were filtered with product_qty > qty_invoiced for every document type, which misses three cases. The first two were already fixed on 18.0 and never reached this branch. * Credit notes: product_qty does not drop with a return, so a fully billed line with a pending credit gave 0 and was hidden. Use qty_to_invoice < 0 on in_refund. * Over receipt on bills: when the vendor delivers more than ordered the line ends up fully ordered but not fully billed (ordered 40, received 41, billed 40), 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. * Orders set as 'Nothing to Bill': the matching view already excludes orders forced as 'No Bill to Receive', but not the other forced status, even though both mean the order is closed for billing. Exclude any forced invoice status. 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 8d0887a commit c4b6f1e

4 files changed

Lines changed: 119 additions & 5 deletions

File tree

purchase_ux/models/account_move.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# directory
44
##############################################################################
55
from odoo import fields, models
6+
from odoo.tools import float_compare
67

78

89
class AccountMove(models.Model):
@@ -100,7 +101,9 @@ def action_purchase_matching(self):
100101
else c
101102
for c in res.get("domain", [])
102103
]
103-
# Show only POLs where ordered qty > invoiced qty (fully invoiced lines excluded).
104+
# Show only POLs with something pending: on a bill, ordered qty not billed yet or qty
105+
# pending to bill (an over receipt is fully ordered but not fully billed); on a credit
106+
# note, the pending refund left by a return on an already fully billed line.
104107
all_pols = self.env["purchase.order.line"].search(
105108
[
106109
("partner_id", "in", commercial_partner.ids),
@@ -109,9 +112,20 @@ def action_purchase_matching(self):
109112
)
110113
# exclude POLs already matched to a line in this bill (qty_invoiced ignores drafts)
111114
already_matched = set(self.invoice_line_ids.filtered("purchase_line_id").mapped("purchase_line_id").ids)
112-
pending_pol_ids = all_pols.filtered(
113-
lambda p: p.product_qty > p.qty_invoiced and p.id not in already_matched
114-
).ids
115+
is_refund = self.move_type == "in_refund"
116+
uom_precision = self.env["decimal.precision"].precision_get("Product Unit of Measure")
117+
118+
def _pending(pol):
119+
if pol.id in already_matched:
120+
return False
121+
if is_refund:
122+
return float_compare(pol.qty_to_invoice, 0.0, precision_digits=uom_precision) < 0
123+
return (
124+
float_compare(pol.product_qty, pol.qty_invoiced, precision_digits=uom_precision) > 0
125+
or float_compare(pol.qty_to_invoice, 0.0, precision_digits=uom_precision) > 0
126+
)
127+
128+
pending_pol_ids = all_pols.filtered(_pending).ids
115129
domain = list(res.get("domain") or [])
116130
domain += ["|", ("pol_id", "=", False), ("pol_id", "in", pending_pol_ids)]
117131
res["domain"] = domain

purchase_ux/models/purchase_bill_line_match.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,13 @@ def _compute_reference_description(self):
3939

4040
@property
4141
def _table_query(self):
42+
# any forced invoice status closes the order for billing, also 'no' (nothing to bill)
4243
return SQL(
4344
"""
4445
SELECT base.* FROM (%s) AS base
4546
WHERE base.purchase_order_id IS NULL
4647
OR base.purchase_order_id NOT IN (
47-
SELECT id FROM purchase_order WHERE force_invoiced_status = 'invoiced'
48+
SELECT id FROM purchase_order WHERE force_invoiced_status IS NOT NULL
4849
)
4950
""",
5051
super()._table_query,

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: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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.group_ids |= 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+
for forced in ("invoiced", "no"):
87+
self.assertNotIn(self._line(100, forced=forced), self._offered())
88+
89+
def test_refund_pending_from_return(self):
90+
line = self._line(600, received=500)
91+
self._bill(line, 600)
92+
self.assertIn(line, self._offered(move_type="in_refund"))
93+
94+
def test_refund_already_credited(self):
95+
line = self._line(600, received=500)
96+
self._bill(line, 600)
97+
self._bill(line, 100, move_type="in_refund")
98+
self.assertNotIn(line, self._offered(move_type="in_refund"))

0 commit comments

Comments
 (0)