Files
sale-tools/sale_outstanding/tests/test_sale_outstanding.py
2026-06-02 12:46:25 +02:00

249 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Copyright 2025 Stéphan Sainléger (Elabore)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo.fields import Command
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged("post_install", "-at_install")
class TestSaleOutstanding(TransactionCase):
"""Tests for sum_outstanding and sum_pending_work computed fields."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True))
cls.partner = cls.env["res.partner"].create({"name": "Test Customer"})
# Storable product — delivered on shipping (delivery policy)
cls.product_storable = cls.env["product.product"].create(
{
"name": "Storable Product",
"type": "consu",
"list_price": 100.0,
"invoice_policy": "delivery",
"taxes_id": [],
}
)
# Service product — invoiced on delivery (timesheet / manual delivery)
cls.product_service = cls.env["product.product"].create(
{
"name": "Service Product",
"type": "service",
"list_price": 50.0,
"invoice_policy": "delivery",
"service_policy": "delivered_timesheet",
"taxes_id": [],
}
)
# Service product with ordered_prepaid policy — must be excluded from pending work.
# service_policy is a computed field on product.template derived from
# invoice_policy + service_type. 'ordered_prepaid' = invoice_policy='order' +
# service_type='timesheet' (requires sale_project/sale_timesheet).
# We set the policy directly on the template when the field exists.
cls.product_service_ordered = cls.env["product.product"].create(
{
"name": "Service Ordered Prepaid",
"type": "service",
"list_price": 80.0,
"invoice_policy": "order",
"service_type": "timesheet",
"taxes_id": [],
}
)
# Force service_policy to 'ordered_prepaid' if the field is available
# (provided by sale_project / sale_timesheet).
if "service_policy" in cls.env["product.template"]._fields:
cls.product_service_ordered.product_tmpl_id.service_policy = "ordered_prepaid"
# -------------------------------------------------------------------------
# Helpers
# -------------------------------------------------------------------------
def _make_order(self, lines):
"""Create and confirm a sale order with the given line specs.
Each entry in *lines* is a dict with keys: product, qty, price_unit.
Returns the confirmed sale.order record.
"""
order = self.env["sale.order"].create(
{
"partner_id": self.partner.id,
"order_line": [
Command.create(
{
"product_id": spec["product"].id,
"product_uom_qty": spec["qty"],
"price_unit": spec["price_unit"],
}
)
for spec in lines
],
}
)
order.action_confirm()
return order
# -------------------------------------------------------------------------
# sum_outstanding
# -------------------------------------------------------------------------
def test_outstanding_zero_before_delivery(self):
"""sum_outstanding is 0 when nothing has been delivered yet."""
order = self._make_order(
[{"product": self.product_storable, "qty": 5.0, "price_unit": 100.0}]
)
self.assertEqual(
order.sum_outstanding,
0.0,
"No delivery done yet — outstanding should be 0.",
)
def test_outstanding_after_partial_delivery(self):
"""sum_outstanding equals delivered-but-not-invoiced amount after partial delivery."""
order = self._make_order(
[{"product": self.product_storable, "qty": 10.0, "price_unit": 100.0}]
)
line = order.order_line
# Simulate partial delivery: 4 units delivered
line.qty_delivered = 4.0
# qty_to_invoice == qty_delivered - qty_invoiced == 4 - 0 == 4
self.assertAlmostEqual(order.sum_outstanding, 4.0 * 100.0)
def test_outstanding_after_full_delivery(self):
"""sum_outstanding equals full order amount when all is delivered but not invoiced."""
order = self._make_order(
[{"product": self.product_storable, "qty": 5.0, "price_unit": 200.0}]
)
order.order_line.qty_delivered = 5.0
self.assertAlmostEqual(order.sum_outstanding, 5.0 * 200.0)
def test_outstanding_zero_after_invoice(self):
"""sum_outstanding drops to 0 once the delivered qty has been fully invoiced."""
order = self._make_order(
[{"product": self.product_storable, "qty": 3.0, "price_unit": 100.0}]
)
line = order.order_line
line.qty_delivered = 3.0
# Simulate invoicing by setting qty_invoiced directly (bypass actual invoice flow)
line.qty_invoiced = 3.0
# qty_to_invoice = max(0, qty_delivered - qty_invoiced) = 0
self.assertAlmostEqual(order.sum_outstanding, 0.0)
def test_outstanding_multi_line(self):
"""sum_outstanding sums across all lines correctly."""
order = self._make_order(
[
{"product": self.product_storable, "qty": 2.0, "price_unit": 100.0},
{"product": self.product_service, "qty": 3.0, "price_unit": 50.0},
]
)
lines = order.order_line
lines[0].qty_delivered = 2.0 # outstanding: 2 × 100 = 200
lines[1].qty_delivered = 1.0 # outstanding: 1 × 50 = 50
self.assertAlmostEqual(order.sum_outstanding, 200.0 + 50.0)
# -------------------------------------------------------------------------
# sum_pending_work
# -------------------------------------------------------------------------
def test_pending_work_equals_full_qty_before_delivery(self):
"""sum_pending_work equals full order amount when nothing has been delivered."""
order = self._make_order(
[{"product": self.product_storable, "qty": 5.0, "price_unit": 100.0}]
)
self.assertAlmostEqual(
order.sum_pending_work,
5.0 * 100.0,
msg="No delivery done — pending work should equal full order amount.",
)
def test_pending_work_decreases_with_delivery(self):
"""sum_pending_work decreases as deliveries are recorded."""
order = self._make_order(
[{"product": self.product_storable, "qty": 10.0, "price_unit": 100.0}]
)
order.order_line.qty_delivered = 7.0
# Remaining: (10 - 7) × 100 = 300
self.assertAlmostEqual(order.sum_pending_work, 3.0 * 100.0)
def test_pending_work_zero_after_full_delivery(self):
"""sum_pending_work is 0 once everything has been delivered."""
order = self._make_order(
[{"product": self.product_storable, "qty": 5.0, "price_unit": 100.0}]
)
order.order_line.qty_delivered = 5.0
self.assertAlmostEqual(order.sum_pending_work, 0.0)
def test_pending_work_excludes_ordered_prepaid_service(self):
"""Products with service_policy='ordered_prepaid' are excluded from pending work."""
if "service_policy" not in self.env["product.template"]._fields:
self.skipTest("service_policy field not available (sale_project not installed)")
order = self._make_order(
[
{"product": self.product_storable, "qty": 2.0, "price_unit": 100.0},
# This line must be ignored in the pending work computation
{
"product": self.product_service_ordered,
"qty": 5.0,
"price_unit": 80.0,
},
]
)
# Only the storable product counts: 2 × 100 = 200
self.assertAlmostEqual(
order.sum_pending_work,
2.0 * 100.0,
msg=(
"ordered_prepaid service should be excluded; "
"only storable product pending work should be counted."
),
)
def test_pending_work_includes_non_ordered_prepaid_service(self):
"""Services with a delivery-based policy ARE included in pending work."""
order = self._make_order(
[
{"product": self.product_storable, "qty": 2.0, "price_unit": 100.0},
{"product": self.product_service, "qty": 4.0, "price_unit": 50.0},
]
)
# Both lines count: 2×100 + 4×50 = 400
self.assertAlmostEqual(order.sum_pending_work, 200.0 + 200.0)
def test_pending_work_multi_line_partial_delivery(self):
"""sum_pending_work aggregates remaining work across multiple partially delivered lines."""
order = self._make_order(
[
{"product": self.product_storable, "qty": 10.0, "price_unit": 100.0},
{"product": self.product_service, "qty": 8.0, "price_unit": 50.0},
]
)
lines = order.order_line
lines[0].qty_delivered = 6.0 # remaining: (10-6) × 100 = 400
lines[1].qty_delivered = 2.0 # remaining: (8-2) × 50 = 300
self.assertAlmostEqual(order.sum_pending_work, 400.0 + 300.0)
# -------------------------------------------------------------------------
# Edge cases
# -------------------------------------------------------------------------
def test_empty_order_both_fields_zero(self):
"""Both fields are 0 on an order with no lines."""
order = self.env["sale.order"].create({"partner_id": self.partner.id})
self.assertEqual(order.sum_outstanding, 0.0)
self.assertEqual(order.sum_pending_work, 0.0)
def test_recompute_on_qty_change(self):
"""Fields recompute correctly when ordered quantity is updated."""
order = self._make_order(
[{"product": self.product_storable, "qty": 5.0, "price_unit": 100.0}]
)
self.assertAlmostEqual(order.sum_pending_work, 5.0 * 100.0)
order.order_line.product_uom_qty = 8.0
self.assertAlmostEqual(order.sum_pending_work, 8.0 * 100.0)