From af20c0150e7c45f37171dc47e1089326c02eb3af Mon Sep 17 00:00:00 2001
From: bosd <5e2fd43-d292-4c90-9d1f-74ff3436329a@anonaddy.me>
Date: Thu, 11 Jun 2026 09:02:38 +0200
Subject: [PATCH 1/5] [IMP] contract: compute and display last_date_invoiced on
contracts
Aggregate the latest invoiced date from the contract lines into a stored
computed field on contract.contract, label it explicitly in the views, and
bump the manifest version.
---
contract/__manifest__.py | 2 +-
contract/models/contract.py | 23 ++++++++++++++++++
contract/models/contract_recurring_mixin.py | 1 +
contract/tests/test_contract.py | 27 +++++++++++++++++++++
contract/views/contract.xml | 7 ++++++
5 files changed, 59 insertions(+), 1 deletion(-)
diff --git a/contract/__manifest__.py b/contract/__manifest__.py
index 644a317788..4b0fc0675c 100644
--- a/contract/__manifest__.py
+++ b/contract/__manifest__.py
@@ -11,7 +11,7 @@
{
"name": "Recurring - Contracts Management",
- "version": "19.0.1.0.0",
+ "version": "19.0.1.1.3",
"category": "Contract Management",
"license": "AGPL-3",
"author": "Tecnativa, ACSONE SA/NV, Odoo Community Association (OCA)",
diff --git a/contract/models/contract.py b/contract/models/contract.py
index 8008e24fa9..7ad95dd0b0 100644
--- a/contract/models/contract.py
+++ b/contract/models/contract.py
@@ -124,6 +124,13 @@ class ContractContract(models.Model):
# === Dates ===
date_end = fields.Date(compute="_compute_date_end", store=True, readonly=False)
+ last_date_invoiced = fields.Date(
+ string="Date of Last Invoice",
+ compute="_compute_last_date_invoiced",
+ store=True,
+ readonly=True,
+ copy=False,
+ )
# === Compute Methods ===
@@ -210,6 +217,21 @@ def _compute_date_end(self):
if date_end and all(date_end):
contract.date_end = max(date_end)
+ @api.depends(
+ "contract_line_ids.last_date_invoiced",
+ "contract_line_ids.is_canceled",
+ )
+ def _compute_last_date_invoiced(self):
+ for contract in self:
+ dates = contract.contract_line_ids.filtered(
+ lambda line: (
+ line.last_date_invoiced
+ and not line.is_canceled
+ and (not line.display_type or line.is_recurring_note)
+ )
+ ).mapped("last_date_invoiced")
+ contract.last_date_invoiced = max(dates) if dates else False
+
def _inverse_partner_id(self):
for rec in self:
if not rec.invoice_partner_id:
@@ -655,6 +677,7 @@ def _recurring_create_invoice(self, date_ref=False):
self._add_contract_origin(moves)
self._invoice_followers(moves)
self._compute_recurring_next_date()
+ self._compute_last_date_invoiced()
return moves
@api.model
diff --git a/contract/models/contract_recurring_mixin.py b/contract/models/contract_recurring_mixin.py
index 88b1659fb3..34dedfd1b0 100644
--- a/contract/models/contract_recurring_mixin.py
+++ b/contract/models/contract_recurring_mixin.py
@@ -70,6 +70,7 @@ class ContractRecurringMixin(models.AbstractModel):
# Define when and how invoices should be issued within the recurrence.
last_date_invoiced = fields.Date(
+ string="Date of Last Invoice",
readonly=True,
copy=False,
)
diff --git a/contract/tests/test_contract.py b/contract/tests/test_contract.py
index c42de5ad82..12e3cca2bd 100644
--- a/contract/tests/test_contract.py
+++ b/contract/tests/test_contract.py
@@ -533,6 +533,33 @@ def test_last_invoice_pre_paid(self):
"Should not create a new invoice after the last one",
)
+ def test_contract_last_date_invoiced(self):
+ """contract.contract.last_date_invoiced aggregates max of line values,
+ excluding cancelled lines and pure section/note rows."""
+ self.assertFalse(self.acct_line.last_date_invoiced)
+ self.assertFalse(self.contract.last_date_invoiced)
+ self.acct_line.date_start = "2018-01-01"
+ self.acct_line.recurring_invoicing_type = "post-paid"
+ self.acct_line.date_end = "2018-03-15"
+ self.contract.recurring_create_invoice()
+ self.assertEqual(self.acct_line.last_date_invoiced, to_date("2018-01-31"))
+ self.assertEqual(self.contract.last_date_invoiced, to_date("2018-01-31"))
+ line2 = self.env["contract.line"].create(
+ dict(self.line_vals, recurring_next_date="2018-04-15")
+ )
+ line2.last_date_invoiced = "2018-03-31"
+ self.assertEqual(self.contract.last_date_invoiced, to_date("2018-03-31"))
+ line2.is_canceled = True
+ self.assertEqual(self.contract.last_date_invoiced, to_date("2018-01-31"))
+ self.env["contract.line"].create(
+ {
+ "contract_id": self.contract.id,
+ "display_type": "line_section",
+ "name": "Header",
+ }
+ )
+ self.assertEqual(self.contract.last_date_invoiced, to_date("2018-01-31"))
+
def test_onchange_partner_id(self):
self.contract._onchange_partner_id()
self.assertEqual(
diff --git a/contract/views/contract.xml b/contract/views/contract.xml
index 50dab8bac4..97d2c383fe 100644
--- a/contract/views/contract.xml
+++ b/contract/views/contract.xml
@@ -416,6 +416,7 @@
widget="many2many_tags"
options="{'color_field': 'color'}"
/>
+
@@ -469,6 +470,12 @@
domain="[('recurring_next_date', '!=', False)]"
context="{'group_by':'recurring_next_date'}"
/>
+
Date: Thu, 11 Jun 2026 09:02:54 +0200
Subject: [PATCH 2/5] [IMP] contract: revamp form, list, search and kanban
views
Add a kanban view and enable the activity view, improve the list and search
view UX, add a partner search group-by, refine the form layout (Other
Information in a single two-column grid, 'Analytic Account' label) and
streamline the contract.tag UX.
---
contract/models/contract.py | 12 ++-
contract/models/contract_tag.py | 9 +-
contract/models/contract_template.py | 1 +
contract/views/contract.xml | 151 ++++++++++++++++++++++-----
contract/views/contract_tag.xml | 21 +---
contract/views/contract_template.xml | 16 ++-
6 files changed, 159 insertions(+), 51 deletions(-)
diff --git a/contract/models/contract.py b/contract/models/contract.py
index 7ad95dd0b0..687a30deb7 100644
--- a/contract/models/contract.py
+++ b/contract/models/contract.py
@@ -39,15 +39,23 @@ class ContractContract(models.Model):
default=lambda self: self.env.user,
)
group_id = fields.Many2one(
- string="Group",
+ string="Analytic Account",
comodel_name="account.analytic.account",
compute="_compute_group_id",
store=True,
readonly=False,
ondelete="restrict",
+ help=(
+ "Analytic account shared by every line of this contract. "
+ "Computed from the lines' analytic distribution when all lines "
+ "point at the same account; left empty when the lines use "
+ "different accounts. Stored compute, editable: clearing or "
+ "setting it manually overrides the computed value."
+ ),
)
tag_ids = fields.Many2many(comodel_name="contract.tag", string="Tags")
- note = fields.Text(string="Notes")
+ color = fields.Integer(string="Color Index")
+ note = fields.Html(string="Notes", sanitize=True)
# === Partner and Commercial Info ===
partner_id = fields.Many2one(
diff --git a/contract/models/contract_tag.py b/contract/models/contract_tag.py
index 9cd98aeaa7..60673d5bb6 100644
--- a/contract/models/contract_tag.py
+++ b/contract/models/contract_tag.py
@@ -1,6 +1,8 @@
# Copyright 2019 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+from random import randint
+
from odoo import fields, models
@@ -8,8 +10,13 @@ class ContractTag(models.Model):
_name = "contract.tag"
_description = "Contract Tag"
+ def _get_default_color(self):
+ return randint(1, 11)
+
name = fields.Char(required=True)
company_id = fields.Many2one(
"res.company", string="Company", default=lambda self: self.env.company
)
- color = fields.Integer("Color Index", default=0)
+ color = fields.Integer(
+ string="Color Index", default=lambda self: self._get_default_color()
+ )
diff --git a/contract/models/contract_template.py b/contract/models/contract_template.py
index b886071e26..dcc7878f50 100644
--- a/contract/models/contract_template.py
+++ b/contract/models/contract_template.py
@@ -20,6 +20,7 @@ class ContractTemplate(models.Model):
# === Basic Info ===
+ active = fields.Boolean(default=True)
name = fields.Char(required=True)
partner_id = fields.Many2one(
comodel_name="res.partner",
diff --git a/contract/views/contract.xml b/contract/views/contract.xml
index 97d2c383fe..1d8b4da140 100644
--- a/contract/views/contract.xml
+++ b/contract/views/contract.xml
@@ -28,7 +28,7 @@
-
-
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
{'default_customer_rank': 1, 'default_supplier_rank': 0,
- 'res_partner_search_mode': 'customer', 'show_vat': True}
+ 'res_partner_search_mode': 'customer', 'show_vat': True,
+ 'show_address': 1}
@@ -392,7 +408,8 @@
{'default_customer_rank': False, 'default_supplier_rank': 1,
- 'res_partner_search_mode': 'supplier', 'show_vat': True}
+ 'res_partner_search_mode': 'supplier', 'show_vat': True,
+ 'show_address': 1}
@@ -401,22 +418,78 @@
+
+
+ contract.contract kanban view (in contract)
+ contract.contract
+
+
+
+
+
+ Open
+ Duplicate
+ Archive
+ Restore
+ Delete
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
contract.contract list view (in contract)
contract.contract
-
+
-
+
+
+
+
+
+
+
@@ -431,22 +504,34 @@
name="name"
filter_domain="['|', ('name','ilike',self), ('code','ilike',self)]"
/>
+
+
+
+
+
-
-
+
+
+
Customer Contracts
contract.contract
- list,form
+ list,kanban,activity,form
customer-contracts
[('contract_type', '=', 'sale')]
@@ -527,7 +622,7 @@
Supplier Contracts
contract.contract
- list,form
+ list,kanban,activity,form
supplier-contracts
[('contract_type', '=', 'purchase')]
diff --git a/contract/views/contract_tag.xml b/contract/views/contract_tag.xml
index dffab7a6c1..e8d85cd3cd 100644
--- a/contract/views/contract_tag.xml
+++ b/contract/views/contract_tag.xml
@@ -2,27 +2,12 @@
-
- contract.tag
-
-
-
-
contract.tag
-
+
+
Contract Tags
contract.tag
- list,form
+ list
contract-tags