249 lines
10 KiB
Python
249 lines
10 KiB
Python
# 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)
|