# 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)