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
19 changes: 18 additions & 1 deletion contract/models/abstract_contract_line.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,24 @@ def _compute_date_start(self):
# pylint: disable=missing-return
@api.depends("contract_id.recurring_next_date", "contract_id.line_recurrence")
def _compute_recurring_next_date(self):
super()._compute_recurring_next_date()
# Preserve manually set recurring_next_date when line_recurrence is enabled.
# The cross-dependency chain (contract depends on line's recurring_next_date,
# and line depends on contract's recurring_next_date) would otherwise
# overwrite user-modified values with the computed value from
# next_period_date_start. We detect manual modifications by comparing
# with _origin and skip recomputation for those records.
to_compute = self.filtered(
lambda r: not (
r.contract_id.line_recurrence
and r._origin
and r._origin.recurring_next_date
and r._origin.recurring_next_date != r.recurring_next_date
)
)
if to_compute:
super(
ContractAbstractContractLine, to_compute
)._compute_recurring_next_date()
self._set_recurrence_field("recurring_next_date")

@api.depends("display_type", "note_invoicing_mode")
Expand Down
79 changes: 79 additions & 0 deletions contract/tests/test_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,85 @@ def test_contract(self):
self.assertAlmostEqual(self.inv_line.price_subtotal, 50.0)
self.assertEqual(self.contract.user_id, self.invoice_monthly.user_id)

def test_preserve_manual_recurring_next_date(self):
"""When a user manually modifies recurring_next_date on a line with
line_recurrence=True and a set last_date_invoiced, the value must
not be reset to the computed next_period_date_start by the
cross-dependency recomputation chain."""
self.acct_line.write(
{
"recurring_rule_type": "monthly",
"recurring_invoicing_type": "pre-paid",
"date_start": "2018-01-01",
"date_end": False,
"last_date_invoiced": "2018-01-31",
}
)
self.acct_line.flush_recordset()
self.assertEqual(self.acct_line.recurring_next_date, to_date("2018-02-01"))
manual_date = to_date("2018-03-15")
# Simulate the One2many modal edit: attribute assignment dirties
# the field but preserves _origin (the parent form's cached value
# from load time). Flush writes the dirty value and triggers the
# cross-dependency cascade: contract.rnd changes → line.rnd is
# invalidated → _compute_recurring_next_date runs. The guard
# sees _origin.rnd (2018-02-01) != current value (2018-03-15)
# and skips recomputation.
self.acct_line.recurring_next_date = manual_date
self.acct_line.flush_recordset()
# contract.rnd was invalidated by the line write above; flush it
# to cascade back.
self.contract.flush_recordset()
self.assertEqual(self.acct_line.recurring_next_date, manual_date)

def test_preserve_manual_rnd_with_stale_other_line(self):
"""A contract with multiple lines where editing one line's
recurring_next_date causes contract.recurring_next_date to change
(the other line becomes the new min). The manual edit must survive
the subsequent recomputation cascade."""
# Line A: the one the user edits — make it the current contract min.
self.acct_line.write(
{
"recurring_rule_type": "monthlylastday",
"recurring_invoicing_type": "pre-paid",
"date_start": "2023-01-01",
"date_end": "2026-09-30",
"last_date_invoiced": "2023-12-31",
}
)
# Line B: higher rnd than line A, lower than the user's new value.
self.env["contract.line"].create(
{
"contract_id": self.contract.id,
"product_id": self.product_2.id,
"name": "Line B",
"quantity": 1,
"uom_id": self.product_2.uom_id.id,
"price_unit": 50,
"discount": 0,
"recurring_rule_type": "monthly",
"recurring_interval": 1,
"recurring_invoicing_type": "pre-paid",
"date_start": "2023-01-01",
"date_end": False,
"last_date_invoiced": "2024-12-31",
"recurring_next_date": "2025-01-01",
}
)
self.acct_line.flush_recordset()
self.contract.flush_recordset()
# contract.rnd = min(2024-01-01, 2025-01-01) = 2024-01-01 (line A)
self.assertEqual(self.contract.recurring_next_date, to_date("2024-01-01"))
# User edits line A's recurring_next_date past line B's, so line B
# becomes the new contract min. This changes contract.rnd, which
# triggers _compute_recurring_next_date back onto line A.
manual_date = to_date("2026-06-30")
self.acct_line.write({"recurring_next_date": manual_date})
# contract.rnd = min(2025-01-01, 2026-06-30) = 2025-01-01
self.assertEqual(self.contract.recurring_next_date, to_date("2025-01-01"))
# The cross-dependency must not overwrite the manual edit.
self.assertEqual(self.acct_line.recurring_next_date, manual_date)

def test_contract_level_recurrence(self):
self.contract3.recurring_create_invoice()
self.contract3.flush_recordset()
Expand Down
Loading