Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions purchase_ux/models/account_move.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 [])
Expand Down
3 changes: 2 additions & 1 deletion purchase_ux/models/purchase_bill_line_match.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 4 additions & 6 deletions purchase_ux/models/purchase_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions purchase_ux/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
# directory
##############################################################################

from . import test_purchase_matching
from . import test_purchase_order
109 changes: 109 additions & 0 deletions purchase_ux/tests/test_purchase_matching.py
Original file line number Diff line number Diff line change
@@ -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"))
2 changes: 1 addition & 1 deletion purchase_ux/views/purchase_order_views.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
</field>

<button name="button_confirm" position="after">
<button name="button_set_invoiced" type="object" string="Set Invoiced" invisible="state != 'done' or invoice_status != 'to invoice'" confirm="This order will be setted as invoiced. This operation can't be undone. Are you sure do you want to continue?" groups="base.group_no_one"/>
<button name="button_set_invoiced" type="object" string="Set Invoiced" invisible="not locked or invoice_status != 'to invoice'" confirm="This order will be setted as invoiced. This operation can't be undone. Are you sure do you want to continue?" groups="base.group_no_one"/>
</button>

<field name="invoice_status" position="before">
Expand Down
Loading