diff --git a/README.md b/README.md index ea5ea50656..a679f95b7a 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Available addons addon | version | maintainers | summary --- | --- | --- | --- [agreement_rebate_partner_company_group](agreement_rebate_partner_company_group/) | 18.0.1.0.0 | | Rebate agreements applied to all company group members -[contract](contract/) | 18.0.2.5.0 | | Recurring - Contracts Management +[contract](contract/) | 18.0.2.5.1 | | Recurring - Contracts Management [contract_analytic_tag](contract_analytic_tag/) | 18.0.1.0.0 | victoralmau | Contract Analytic Tag [contract_forecast](contract_forecast/) | 18.0.1.0.1 | sbejaoui | Contract Forecast [contract_forecast_variable_quantity](contract_forecast_variable_quantity/) | 18.0.1.0.0 | | Contract Forecast Variable Quantity @@ -42,6 +42,7 @@ addon | version | maintainers | summary [contract_sale_mandate](contract_sale_mandate/) | 18.0.1.0.0 | | This module manages the banking mandate from the sale order to the contract. [contract_sale_payment_mode](contract_sale_payment_mode/) | 18.0.1.0.0 | | This addon manages payment mode from sale order to contract. [contract_sale_transmit_method](contract_sale_transmit_method/) | 18.0.1.0.0 | | Propagate transmit method (email, post, portal, ...) from sale orders to contracts. +[contract_service_dates](contract_service_dates/) | 18.0.1.0.0 | | This module adds Service Dates fields to contract lines [contract_termination](contract_termination/) | 18.0.1.0.0 | | contract_termination [contract_transmit_method](contract_transmit_method/) | 18.0.1.1.0 | | Set transmit method (email, post, portal, ...) in contracts and propagate it to invoices. [contract_update_last_date_invoiced](contract_update_last_date_invoiced/) | 18.0.1.0.1 | rafaelbn Andrii9090 | This module allows to update the last date invoiced if invoices are deleted. diff --git a/contract/README.rst b/contract/README.rst index 0e8c685553..6d7d7db1fc 100644 --- a/contract/README.rst +++ b/contract/README.rst @@ -11,7 +11,7 @@ Recurring - Contracts Management !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:f31a70112d86e7b2f6237d0ad274d0a55e384fe483eb0b1a4fe4a0fca1b05c9a + !! source digest: sha256:18b03b2e2527c4875b06eb16fdea75963f902195518f9c5c755649aa89352108 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Production%2FStable-green.png diff --git a/contract/__manifest__.py b/contract/__manifest__.py index c31d17ee55..9753be2b45 100644 --- a/contract/__manifest__.py +++ b/contract/__manifest__.py @@ -11,7 +11,7 @@ { "name": "Recurring - Contracts Management", - "version": "18.0.2.5.0", + "version": "18.0.2.5.1", "category": "Contract Management", "license": "AGPL-3", "author": "Tecnativa, ACSONE SA/NV, Odoo Community Association (OCA)", diff --git a/contract/models/contract_line.py b/contract/models/contract_line.py index a86aa706f8..23efbc4744 100644 --- a/contract/models/contract_line.py +++ b/contract/models/contract_line.py @@ -361,9 +361,32 @@ def _get_contract_line_total_value(self): self.display_name, ) ) - quantity = self._get_quantity_to_invoice( - self.date_start, - self.date_end, - self.date_start, + + period_length = self.get_relative_delta( + self.recurring_rule_type, self.recurring_interval + ) + currency = ( + self.contract_id.pricelist_id.currency_id + if self.contract_id.pricelist_id + else self.currency_id ) - return quantity * self.price_unit + + current_period_start = self.date_start + total_amount = 0.0 + while current_period_start <= self.date_end: + current_period_end = ( + current_period_start + period_length - relativedelta(days=1) + ) + if current_period_end > self.date_end: + current_period_end = self.date_end + qty = self._get_quantity_to_invoice( + current_period_start, + current_period_end, + current_period_start, # Mock an invoice date at period start + ) + subtotal = qty * self.price_unit * (1 - (self.discount / 100.0)) + if currency: + subtotal = currency.round(subtotal) + total_amount += subtotal + current_period_start += period_length + return total_amount diff --git a/contract/static/description/index.html b/contract/static/description/index.html index 736d38a5a2..aa4bd95ab9 100644 --- a/contract/static/description/index.html +++ b/contract/static/description/index.html @@ -372,7 +372,7 @@

Recurring - Contracts Management

!! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:f31a70112d86e7b2f6237d0ad274d0a55e384fe483eb0b1a4fe4a0fca1b05c9a +!! source digest: sha256:18b03b2e2527c4875b06eb16fdea75963f902195518f9c5c755649aa89352108 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

Production/Stable License: AGPL-3 OCA/contract Translate me on Weblate Try me on Runboat

This module enables contracts management with recurring invoicing diff --git a/contract/tests/test_contract.py b/contract/tests/test_contract.py index 96073b686f..6a4727f49a 100644 --- a/contract/tests/test_contract.py +++ b/contract/tests/test_contract.py @@ -11,6 +11,7 @@ from odoo import Command, fields from odoo.exceptions import UserError, ValidationError +from odoo.fields import Date from odoo.tests import Form, common @@ -1617,33 +1618,41 @@ def test_get_contract_line_total_value_basic(self): { "quantity": 3, "price_unit": 400.0, + "discount": 0.0, "date_start": "2026-01-01", "date_end": "2026-12-31", - "recurring_next_date": "2026-01-01", + "recurring_interval": 1, + "recurring_rule_type": "monthly", } ) - self.assertEqual(self.acct_line._get_contract_line_total_value(), 3 * 400.0) + # 12 months * (3 * 400.0) = 14,400.0 + self.assertEqual(self.acct_line._get_contract_line_total_value(), 14400.0) def test_get_contract_line_total_value_single_unit(self): self.acct_line.write( { "quantity": 1, "price_unit": 1200.0, + "discount": 0.0, "date_start": "2026-01-01", "date_end": "2026-12-31", - "recurring_next_date": "2026-01-01", + "recurring_interval": 1, + "recurring_rule_type": "monthly", } ) - self.assertEqual(self.acct_line._get_contract_line_total_value(), 1200.0) + # 12 months * 1200.0 = 14,400.0 + self.assertEqual(self.acct_line._get_contract_line_total_value(), 14400.0) def test_get_contract_line_total_value_zero_price(self): self.acct_line.write( { "quantity": 5, "price_unit": 0.0, + "discount": 0.0, "date_start": "2026-01-01", "date_end": "2026-06-30", - "recurring_next_date": "2026-01-01", + "recurring_interval": 1, + "recurring_rule_type": "monthly", } ) self.assertEqual(self.acct_line._get_contract_line_total_value(), 0.0) @@ -1657,8 +1666,8 @@ def test_get_contract_line_total_value_no_date_end_raises(self): "name": "Test", "quantity": 1, "price_unit": 1000.0, + "discount": 0.0, "date_start": to_date("2026-01-01"), - "recurring_next_date": to_date("2026-01-01"), "recurring_interval": 1, "recurring_rule_type": "monthly", "recurring_invoicing_type": "pre-paid", @@ -1668,3 +1677,49 @@ def test_get_contract_line_total_value_no_date_end_raises(self): line.date_end = False with self.assertRaises(UserError): line._get_contract_line_total_value() + + def test_get_contract_line_total_value_with_discount(self): + self.acct_line.write( + { + "quantity": 2, + "price_unit": 100.0, + "discount": 10.0, + "date_start": "2026-01-01", + "date_end": "2026-12-31", + "recurring_interval": 1, + "recurring_rule_type": "monthly", + } + ) + # Per period: 2 * 100 * 0.90 = 180.0 + # Total (12 periods): 2160.0 + self.assertEqual(self.acct_line._get_contract_line_total_value(), 2160.0) + + def test_contract_line_total_value_period_prorata(self): + """Test a contract that terminates mid-way through an invoicing period.""" + self.acct_line.write( + { + "quantity": 1, + "price_unit": 100.0, + "discount": 0.0, + "date_start": "2026-01-01", + "date_end": "2026-02-15", + "recurring_interval": 1, + "recurring_rule_type": "monthly", + } + ) + + expected_qty_period_1 = self.acct_line._get_quantity_to_invoice( + Date.to_date("2026-01-01"), + Date.to_date("2026-01-31"), + Date.to_date("2026-01-01"), + ) + expected_qty_period_2 = self.acct_line._get_quantity_to_invoice( + Date.to_date("2026-02-01"), + Date.to_date("2026-02-15"), + Date.to_date("2026-02-01"), + ) + + expected_total = (expected_qty_period_1 + expected_qty_period_2) * 100.0 + self.assertEqual( + self.acct_line._get_contract_line_total_value(), expected_total + ) diff --git a/contract_service_dates/README.rst b/contract_service_dates/README.rst new file mode 100644 index 0000000000..bca34517bb --- /dev/null +++ b/contract_service_dates/README.rst @@ -0,0 +1,108 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +====================== +Contract Service Dates +====================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:f5918b19fed1521629a39f7c85979c385121f87628d91fecd00889f2c7d699f4 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fcontract-lightgray.png?logo=github + :target: https://github.com/OCA/contract/tree/18.0/contract_service_dates + :alt: OCA/contract +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/contract-18-0/contract-18-0-contract_service_dates + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/contract&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module adds **Service Start Date** and **Service End Date** fields +to contract lines, allowing you to track the actual service period +independently from the billing dates. + +**Table of contents** + +.. contents:: + :local: + +Use Cases / Context +=================== + +Contract lines in the base contract module carry a single start date and +end date that drive the recurring invoicing schedule. There is no +dedicated field to record the period during which the service is +actually delivered, which is often distinct from the billing period. +This module fills that gap. + +Usage +===== + +On a contract line form, a new **Service Dates** group is displayed in +the **Recurrence** tab, below the contract line start and end dates: + +- **Service Start Date**: start of the service period. Populated + automatically from the contract line start date and kept in sync with + it unless manually overridden. +- **Service End Date**: end of the service period. Populated + automatically from the contract line end date and kept in sync with it + unless manually overridden. + +Once a service date is set to a value different from its corresponding +contract line date, it is considered decoupled and will no longer be +updated automatically when the contract line date changes. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* ACSONE SA/NV + +Contributors +------------ + +- Benoit Aimont benoit.aimont@acsone.eu (https://www.acsone.eu/) + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/contract `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/contract_service_dates/__init__.py b/contract_service_dates/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/contract_service_dates/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/contract_service_dates/__manifest__.py b/contract_service_dates/__manifest__.py new file mode 100644 index 0000000000..23e6bdb08a --- /dev/null +++ b/contract_service_dates/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Contract Service Dates", + "summary": """This module adds Service Dates fields to contract lines""", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "author": "ACSONE SA/NV, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/contract", + "depends": [ + "contract", + ], + "data": [ + "views/contract_line.xml", + ], + "installable": True, +} diff --git a/contract_service_dates/i18n/contract_service_dates.pot b/contract_service_dates/i18n/contract_service_dates.pot new file mode 100644 index 0000000000..ca155fc6e1 --- /dev/null +++ b/contract_service_dates/i18n/contract_service_dates.pot @@ -0,0 +1,48 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * contract_service_dates +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: contract_service_dates +#: model:ir.model,name:contract_service_dates.model_contract_line +msgid "Contract Line" +msgstr "" + +#. module: contract_service_dates +#: model:ir.model.fields,help:contract_service_dates.field_contract_line__service_end_date +msgid "" +"End date of the service period covered by this contract line. Defaults to " +"the contract line end date and follows it unless manually overridden." +msgstr "" + +#. module: contract_service_dates +#: model_terms:ir.ui.view,arch_db:contract_service_dates.contract_line_form_view +msgid "Service Dates" +msgstr "" + +#. module: contract_service_dates +#: model:ir.model.fields,field_description:contract_service_dates.field_contract_line__service_end_date +msgid "Service End Date" +msgstr "" + +#. module: contract_service_dates +#: model:ir.model.fields,field_description:contract_service_dates.field_contract_line__service_start_date +msgid "Service Start Date" +msgstr "" + +#. module: contract_service_dates +#: model:ir.model.fields,help:contract_service_dates.field_contract_line__service_start_date +msgid "" +"Start date of the service period covered by this contract line. Defaults to " +"the contract line start date and follows it unless manually overridden." +msgstr "" diff --git a/contract_service_dates/models/__init__.py b/contract_service_dates/models/__init__.py new file mode 100644 index 0000000000..6143a36501 --- /dev/null +++ b/contract_service_dates/models/__init__.py @@ -0,0 +1 @@ +from . import contract_line diff --git a/contract_service_dates/models/contract_line.py b/contract_service_dates/models/contract_line.py new file mode 100644 index 0000000000..6453b6da4c --- /dev/null +++ b/contract_service_dates/models/contract_line.py @@ -0,0 +1,58 @@ +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class ContractLine(models.Model): + _inherit = "contract.line" + + service_start_date = fields.Date( + help="Start date of the service period covered by this contract line. " + "Defaults to the contract line start date and follows it unless " + "manually overridden.", + ) + service_end_date = fields.Date( + help="End date of the service period covered by this contract line. " + "Defaults to the contract line end date and follows it unless " + "manually overridden.", + ) + + def _get_service_date_create_vals(self, vals): + """Return service date vals to initialize on create.""" + result = {} + if vals.get("date_start") and not vals.get("service_start_date"): + result["service_start_date"] = vals["date_start"] + if vals.get("date_end") and not vals.get("service_end_date"): + result["service_end_date"] = vals["date_end"] + return result + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + vals.update(self._get_service_date_create_vals(vals)) + return super().create(vals_list) + + def _get_service_date_sync_vals(self, vals): + """Return service date vals to sync from contract date vals.""" + self.ensure_one() + service_vals = {} + if "date_start" in vals and self.service_start_date == self.date_start: + service_vals["service_start_date"] = vals["date_start"] + if "date_end" in vals and self.service_end_date == self.date_end: + service_vals["service_end_date"] = vals["date_end"] + return service_vals + + def write(self, vals): + if not self.env.context.get("_sync_service_dates"): + service_vals_by_id = {} + for rec in self: + service_vals = rec._get_service_date_sync_vals(vals) + if service_vals: + service_vals_by_id[rec.id] = service_vals + for rec in self: + if rec.id in service_vals_by_id: + rec.with_context(_sync_service_dates=True).write( + service_vals_by_id[rec.id] + ) + return super().write(vals) diff --git a/contract_service_dates/pyproject.toml b/contract_service_dates/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/contract_service_dates/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/contract_service_dates/readme/CONTEXT.md b/contract_service_dates/readme/CONTEXT.md new file mode 100644 index 0000000000..de57297386 --- /dev/null +++ b/contract_service_dates/readme/CONTEXT.md @@ -0,0 +1,4 @@ +Contract lines in the base contract module carry a single start date and end +date that drive the recurring invoicing schedule. There is no dedicated field +to record the period during which the service is actually delivered, which is +often distinct from the billing period. This module fills that gap. diff --git a/contract_service_dates/readme/CONTRIBUTORS.md b/contract_service_dates/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..1fbb3f1bf0 --- /dev/null +++ b/contract_service_dates/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Benoit Aimont (https://www.acsone.eu/) diff --git a/contract_service_dates/readme/DESCRIPTION.md b/contract_service_dates/readme/DESCRIPTION.md new file mode 100644 index 0000000000..ee04e3c2f0 --- /dev/null +++ b/contract_service_dates/readme/DESCRIPTION.md @@ -0,0 +1,3 @@ +This module adds **Service Start Date** and **Service End Date** fields to +contract lines, allowing you to track the actual service period independently +from the billing dates. diff --git a/contract_service_dates/readme/USAGE.md b/contract_service_dates/readme/USAGE.md new file mode 100644 index 0000000000..5a2ad71f39 --- /dev/null +++ b/contract_service_dates/readme/USAGE.md @@ -0,0 +1,13 @@ +On a contract line form, a new **Service Dates** group is displayed in the +**Recurrence** tab, below the contract line start and end dates: + +- **Service Start Date**: start of the service period. Populated automatically + from the contract line start date and kept in sync with it unless manually + overridden. +- **Service End Date**: end of the service period. Populated automatically from + the contract line end date and kept in sync with it unless manually + overridden. + +Once a service date is set to a value different from its corresponding contract +line date, it is considered decoupled and will no longer be updated +automatically when the contract line date changes. diff --git a/contract_service_dates/static/description/icon.png b/contract_service_dates/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/contract_service_dates/static/description/icon.png differ diff --git a/contract_service_dates/static/description/index.html b/contract_service_dates/static/description/index.html new file mode 100644 index 0000000000..68de22a185 --- /dev/null +++ b/contract_service_dates/static/description/index.html @@ -0,0 +1,457 @@ + + + + + +README.rst + + + +

+ + + +Odoo Community Association + +
+

Contract Service Dates

+ +

Beta License: AGPL-3 OCA/contract Translate me on Weblate Try me on Runboat

+

This module adds Service Start Date and Service End Date fields +to contract lines, allowing you to track the actual service period +independently from the billing dates.

+

Table of contents

+ +
+

Use Cases / Context

+

Contract lines in the base contract module carry a single start date and +end date that drive the recurring invoicing schedule. There is no +dedicated field to record the period during which the service is +actually delivered, which is often distinct from the billing period. +This module fills that gap.

+
+
+

Usage

+

On a contract line form, a new Service Dates group is displayed in +the Recurrence tab, below the contract line start and end dates:

+
    +
  • Service Start Date: start of the service period. Populated +automatically from the contract line start date and kept in sync with +it unless manually overridden.
  • +
  • Service End Date: end of the service period. Populated +automatically from the contract line end date and kept in sync with it +unless manually overridden.
  • +
+

Once a service date is set to a value different from its corresponding +contract line date, it is considered decoupled and will no longer be +updated automatically when the contract line date changes.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • ACSONE SA/NV
  • +
+
+ +
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/contract project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/contract_service_dates/tests/__init__.py b/contract_service_dates/tests/__init__.py new file mode 100644 index 0000000000..46011f4449 --- /dev/null +++ b/contract_service_dates/tests/__init__.py @@ -0,0 +1 @@ +from . import test_contract_service_dates diff --git a/contract_service_dates/tests/test_contract_service_dates.py b/contract_service_dates/tests/test_contract_service_dates.py new file mode 100644 index 0000000000..09e3831d26 --- /dev/null +++ b/contract_service_dates/tests/test_contract_service_dates.py @@ -0,0 +1,100 @@ +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from datetime import date + +from odoo.tests.common import TransactionCase + + +class TestContractServiceDates(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env["res.partner"].create({"name": "Test Partner"}) + cls.product = cls.env["product.product"].create( + {"name": "Test Product", "type": "service"} + ) + journal = cls.env["account.journal"].search( + [("type", "=", "sale"), ("company_id", "=", cls.env.company.id)], + limit=1, + ) + cls.contract = cls.env["contract.contract"].create( + { + "name": "Test Contract", + "partner_id": cls.partner.id, + "journal_id": journal.id, + "contract_type": "sale", + "line_recurrence": True, + } + ) + + def _create_line(self, date_start, date_end=None, **extra): + vals = { + "contract_id": self.contract.id, + "product_id": self.product.id, + "name": "Test line", + "quantity": 1.0, + "price_unit": 100.0, + "date_start": date_start, + "recurring_next_date": date_start, + "recurring_rule_type": "monthly", + "recurring_invoicing_type": "pre-paid", + "recurring_interval": 1, + } + if date_end is not None: + vals["date_end"] = date_end + vals.update(extra) + return self.env["contract.line"].create(vals) + + def test_create_copies_dates(self): + """service dates are initialised from date_start / date_end on create.""" + line = self._create_line(date(2025, 1, 1), date(2025, 12, 31)) + self.assertEqual(line.service_start_date, date(2025, 1, 1)) + self.assertEqual(line.service_end_date, date(2025, 12, 31)) + + def test_create_no_end_date(self): + """service_end_date is False when date_end is not set.""" + line = self._create_line(date(2025, 1, 1)) + self.assertEqual(line.service_start_date, date(2025, 1, 1)) + self.assertFalse(line.service_end_date) + + def test_create_explicit_service_dates_preserved(self): + """Explicitly supplied service dates are not overwritten on create.""" + line = self._create_line( + date(2025, 1, 1), + date(2025, 12, 31), + service_start_date=date(2025, 3, 1), + service_end_date=date(2025, 9, 30), + ) + self.assertEqual(line.service_start_date, date(2025, 3, 1)) + self.assertEqual(line.service_end_date, date(2025, 9, 30)) + + def test_write_date_start_syncs_service_start(self): + """Modifying date_start updates service_start_date when still in sync.""" + line = self._create_line(date(2025, 1, 1), date(2025, 12, 31)) + line.write({"date_start": date(2025, 2, 1)}) + self.assertEqual(line.service_start_date, date(2025, 2, 1)) + + def test_write_date_end_syncs_service_end(self): + """Modifying date_end updates service_end_date when still in sync.""" + line = self._create_line(date(2025, 1, 1), date(2025, 12, 31)) + line.write({"date_end": date(2025, 6, 30)}) + self.assertEqual(line.service_end_date, date(2025, 6, 30)) + + def test_write_date_start_does_not_overwrite_decoupled_service_start(self): + """Modifying date_start leaves service_start_date alone when decoupled.""" + line = self._create_line(date(2025, 1, 1), date(2025, 12, 31)) + # Manually decouple service_start_date + line.write({"service_start_date": date(2025, 3, 1)}) + # Now change date_start — service_start_date must not follow + line.write({"date_start": date(2025, 2, 1)}) + self.assertEqual(line.service_start_date, date(2025, 3, 1)) + + def test_write_date_end_does_not_overwrite_decoupled_service_end(self): + """Modifying date_end leaves service_end_date alone when decoupled.""" + line = self._create_line(date(2025, 1, 1), date(2025, 12, 31)) + # Manually decouple service_end_date + line.write({"service_end_date": date(2025, 9, 30)}) + # Now change date_end — service_end_date must not follow + line.write({"date_end": date(2026, 12, 31)}) + self.assertEqual(line.service_end_date, date(2025, 9, 30)) diff --git a/contract_service_dates/views/contract_line.xml b/contract_service_dates/views/contract_line.xml new file mode 100644 index 0000000000..12c55a4596 --- /dev/null +++ b/contract_service_dates/views/contract_line.xml @@ -0,0 +1,22 @@ + + + + + contract.line form view (in contract_service_dates) + contract.line + + + + + + + + + + + + + + + diff --git a/setup/_metapackage/pyproject.toml b/setup/_metapackage/pyproject.toml index 7ef6614daf..fa4079e2b8 100644 --- a/setup/_metapackage/pyproject.toml +++ b/setup/_metapackage/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "odoo-addons-oca-contract" -version = "18.0.20260227.1" +version = "18.0.20260728.0" dependencies = [ "odoo-addon-agreement_rebate_partner_company_group==18.0.*", "odoo-addon-contract==18.0.*", @@ -22,6 +22,7 @@ dependencies = [ "odoo-addon-contract_sale_mandate==18.0.*", "odoo-addon-contract_sale_payment_mode==18.0.*", "odoo-addon-contract_sale_transmit_method==18.0.*", + "odoo-addon-contract_service_dates==18.0.*", "odoo-addon-contract_termination==18.0.*", "odoo-addon-contract_transmit_method==18.0.*", "odoo-addon-contract_update_last_date_invoiced==18.0.*",