From 33743841012b29bf4064d16dcd2f91195bae93c7 Mon Sep 17 00:00:00 2001 From: "Pedro M. Baeza" Date: Tue, 14 Jul 2020 20:23:08 +0200 Subject: [PATCH] [IMP+REF] contract: Allow to set recurrency at header level Big refactoring for allowing to define recurrency at header level for simplifying the use of the module for most of the cases where you don't need different recurrency at line level. --- contract/README.rst | 1 + contract/models/__init__.py | 1 + contract/models/abstract_contract.py | 39 +-- contract/models/abstract_contract_line.py | 99 ++++---- contract/models/account_move.py | 2 +- contract/models/contract.py | 32 ++- contract/models/contract_line.py | 241 ++++--------------- contract/models/contract_recurrency_mixin.py | 233 ++++++++++++++++++ contract/readme/ROADMAP.rst | 1 + contract/static/description/index.html | 1 + contract/tests/test_contract.py | 85 +++---- contract/views/contract.xml | 151 +++++++++++- 12 files changed, 541 insertions(+), 345 deletions(-) create mode 100644 contract/models/contract_recurrency_mixin.py diff --git a/contract/README.rst b/contract/README.rst index 320ce0888..c2592f9d3 100644 --- a/contract/README.rst +++ b/contract/README.rst @@ -74,6 +74,7 @@ Known issues / Roadmap ====================== * Recover states and others functional fields in Contracts. +* Add recurrence flag at template level. Bug Tracker =========== diff --git a/contract/models/__init__.py b/contract/models/__init__.py index a8107a94f..82e9c3b11 100644 --- a/contract/models/__init__.py +++ b/contract/models/__init__.py @@ -1,5 +1,6 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from . import contract_recurrency_mixin # should be first from . import abstract_contract from . import abstract_contract_line from . import contract_template diff --git a/contract/models/abstract_contract.py b/contract/models/abstract_contract.py index 18753c3bb..377f32f14 100644 --- a/contract/models/abstract_contract.py +++ b/contract/models/abstract_contract.py @@ -10,6 +10,7 @@ from odoo import api, fields, models class ContractAbstractContract(models.AbstractModel): + _inherit = "contract.recurrency.basic.mixin" _name = "contract.abstract.contract" _description = "Abstract Recurring Contract" @@ -27,12 +28,13 @@ class ContractAbstractContract(models.AbstractModel): default="sale", index=True, ) - journal_id = fields.Many2one( - "account.journal", + comodel_name="account.journal", string="Journal", - default=lambda s: s._default_journal(), domain="[('type', '=', contract_type)," "('company_id', '=', company_id)]", + compute="_compute_journal_id", + store=True, + readonly=False, index=True, ) company_id = fields.Many2one( @@ -41,6 +43,11 @@ class ContractAbstractContract(models.AbstractModel): required=True, default=lambda self: self.env.company.id, ) + line_recurrence = fields.Boolean( + string="Recurrence at line level?", + help="Mark this check if you want to control recurrrence at line level instead" + " of all together for the whole contract.", + ) @api.onchange("contract_type") def _onchange_contract_type(self): @@ -48,19 +55,15 @@ class ContractAbstractContract(models.AbstractModel): self.contract_line_ids.filtered("automatic_price").update( {"automatic_price": False} ) - self.journal_id = self.env["account.journal"].search( - [ - ("type", "=", self.contract_type), - ("company_id", "=", self.company_id.id), - ], - limit=1, - ) - @api.model - def _default_journal(self): - company_id = self.env.context.get("company_id", self.env.user.company_id.id) - domain = [ - ("type", "=", self.contract_type), - ("company_id", "=", company_id), - ] - return self.env["account.journal"].search(domain, limit=1) + @api.depends("contract_type", "company_id") + def _compute_journal_id(self): + AccountJournal = self.env["account.journal"] + for contract in self: + domain = [ + ("type", "=", contract.contract_type), + ("company_id", "=", contract.company_id.id), + ] + journal = AccountJournal.search(domain, limit=1) + if journal: + contract.journal_id = journal.id diff --git a/contract/models/abstract_contract_line.py b/contract/models/abstract_contract_line.py index fb3b80ac9..f20870556 100644 --- a/contract/models/abstract_contract_line.py +++ b/contract/models/abstract_contract_line.py @@ -12,6 +12,7 @@ from odoo.tools.translate import _ class ContractAbstractContractLine(models.AbstractModel): + _inherit = "contract.recurrency.basic.mixin" _name = "contract.abstract.contract.line" _description = "Abstract Recurring Contract Line" @@ -47,46 +48,29 @@ class ContractAbstractContractLine(models.AbstractModel): help="Sequence of the contract line when displaying contracts", ) recurring_rule_type = fields.Selection( - [ - ("daily", "Day(s)"), - ("weekly", "Week(s)"), - ("monthly", "Month(s)"), - ("monthlylastday", "Month(s) last day"), - ("quarterly", "Quarter(s)"), - ("semesterly", "Semester(s)"), - ("yearly", "Year(s)"), - ], - default="monthly", - string="Recurrence", - help="Specify Interval for automatic invoice generation.", + compute="_compute_recurring_rule_type", + store=True, + readonly=False, required=True, + copy=True, ) recurring_invoicing_type = fields.Selection( - [("pre-paid", "Pre-paid"), ("post-paid", "Post-paid")], - default="pre-paid", - string="Invoicing type", - help=( - "Specify if the invoice must be generated at the beginning " - "(pre-paid) or end (post-paid) of the period." - ), + compute="_compute_recurring_invoicing_type", + store=True, + readonly=False, required=True, - ) - recurring_invoicing_offset = fields.Integer( - compute="_compute_recurring_invoicing_offset", - string="Invoicing offset", - help=( - "Number of days to offset the invoice from the period end " - "date (in post-paid mode) or start date (in pre-paid mode)." - ), + copy=True, ) recurring_interval = fields.Integer( - default=1, - string="Invoice Every", - help="Invoice every (Days/Week/Month/Year)", + compute="_compute_recurring_interval", + store=True, + readonly=False, required=True, + copy=True, + ) + date_start = fields.Date( + compute="_compute_date_start", store=True, readonly=False, copy=True, ) - date_start = fields.Date(string="Date Start") - recurring_next_date = fields.Date(string="Date of Next Invoice") last_date_invoiced = fields.Date(string="Last Date Invoiced") is_canceled = fields.Boolean(string="Canceled", default=False) is_auto_renew = fields.Boolean(string="Auto Renew", default=False) @@ -138,17 +122,38 @@ class ContractAbstractContractLine(models.AbstractModel): is_recurring_note = fields.Boolean(compute="_compute_is_recurring_note") company_id = fields.Many2one(related="contract_id.company_id", store=True) - @api.model - def _get_default_recurring_invoicing_offset( - self, recurring_invoicing_type, recurring_rule_type - ): - if ( - recurring_invoicing_type == "pre-paid" - or recurring_rule_type == "monthlylastday" - ): - return 0 - else: - return 1 + def _set_recurrence_field(self, field): + """Helper method for computed methods that gets the equivalent field + in the header. + + We need to re-assign the original value for avoiding a missing error. + """ + for record in self: + if record.contract_id.line_recurrence: + record[field] = record[field] + else: + record[field] = record.contract_id[field] + + @api.depends("contract_id.recurring_rule_type", "contract_id.line_recurrence") + def _compute_recurring_rule_type(self): + self._set_recurrence_field("recurring_rule_type") + + @api.depends("contract_id.recurring_invoicing_type", "contract_id.line_recurrence") + def _compute_recurring_invoicing_type(self): + self._set_recurrence_field("recurring_invoicing_type") + + @api.depends("contract_id.recurring_interval", "contract_id.line_recurrence") + def _compute_recurring_interval(self): + self._set_recurrence_field("recurring_interval") + + @api.depends("contract_id.date_start", "contract_id.line_recurrence") + def _compute_date_start(self): + self._set_recurrence_field("date_start") + + @api.depends("contract_id.recurring_next_date", "contract_id.line_recurrence") + def _compute_recurring_next_date(self): + super()._compute_recurring_next_date() + self._set_recurrence_field("recurring_next_date") @api.depends("display_type", "note_invoicing_mode") def _compute_is_recurring_note(self): @@ -158,14 +163,6 @@ class ContractAbstractContractLine(models.AbstractModel): and record.note_invoicing_mode == "custom" ) - @api.depends("recurring_invoicing_type", "recurring_rule_type") - def _compute_recurring_invoicing_offset(self): - for rec in self: - method = self._get_default_recurring_invoicing_offset - rec.recurring_invoicing_offset = method( - rec.recurring_invoicing_type, rec.recurring_rule_type - ) - @api.depends( "automatic_price", "specific_price", diff --git a/contract/models/account_move.py b/contract/models/account_move.py index ba2528cb8..6e957f89b 100644 --- a/contract/models/account_move.py +++ b/contract/models/account_move.py @@ -6,7 +6,7 @@ from odoo import fields, models -class AccountInvoice(models.Model): +class AccountMove(models.Model): _inherit = "account.move" # We keep this field for migration purpose diff --git a/contract/models/contract.py b/contract/models/contract.py index b381d5dc5..06955ebb3 100644 --- a/contract/models/contract.py +++ b/contract/models/contract.py @@ -20,6 +20,7 @@ class ContractContract(models.Model): "mail.thread", "mail.activity.mixin", "contract.abstract.contract", + "contract.recurrency.mixin", ] active = fields.Boolean(default=True,) @@ -43,6 +44,16 @@ class ContractContract(models.Model): inverse_name="contract_id", copy=True, ) + # Trick for being able to have 2 different views for the same o2m + # We need this as one2many widget doesn't allow to define in the view + # the same field 2 times with different views. 2 views are needed because + # one of them must be editable inline and the other not, which can't be + # parametrized through attrs. + contract_line_fixed_ids = fields.One2many( + string="Contract lines (fixed)", + comodel_name="contract.line", + inverse_name="contract_id", + ) user_id = fields.Many2one( comodel_name="res.users", @@ -53,12 +64,7 @@ class ContractContract(models.Model): create_invoice_visibility = fields.Boolean( compute="_compute_create_invoice_visibility" ) - recurring_next_date = fields.Date( - compute="_compute_recurring_next_date", - string="Date of Next Invoice", - store=True, - ) - date_end = fields.Date(compute="_compute_date_end", string="Date End", store=True) + date_end = fields.Date(compute="_compute_date_end", store=True, readonly=False) payment_term_id = fields.Many2one( comodel_name="account.payment.term", string="Payment Terms", index=True ) @@ -122,6 +128,8 @@ class ContractContract(models.Model): .search([("contract_line_id", "in", self.contract_line_ids.ids,)]) .mapped("move_id") ) + # we are forced to always search for this for not losing possible <=v11 + # generated invoices invoices |= self.env["account.move"].search([("old_contract_id", "=", self.id)]) return invoices @@ -198,10 +206,15 @@ class ContractContract(models.Model): and (not l.display_type or l.is_recurring_note) ) ).mapped("recurring_next_date") - if recurring_next_date: - contract.recurring_next_date = min(recurring_next_date) + # we give priority to computation from date_start if modified + if ( + contract._origin + and contract._origin.date_start != contract.date_start + or not recurring_next_date + ): + super(ContractContract, contract)._compute_recurring_next_date() else: - contract.recurring_next_date = False + contract.recurring_next_date = min(recurring_next_date) @api.depends("contract_line_ids.create_invoice_visibility") def _compute_create_invoice_visibility(self): @@ -276,7 +289,6 @@ class ContractContract(models.Model): vals["date_start"] = fields.Date.context_today(contract_line) vals["recurring_next_date"] = fields.Date.context_today(contract_line) new_lines += contract_line_model.new(vals) - new_lines._onchange_date_start() new_lines._onchange_is_auto_renew() return new_lines diff --git a/contract/models/contract_line.py b/contract/models/contract_line.py index 3be185e92..e3122b101 100644 --- a/contract/models/contract_line.py +++ b/contract/models/contract_line.py @@ -16,7 +16,10 @@ from .contract_line_constraints import get_allowed class ContractLine(models.Model): _name = "contract.line" _description = "Contract Line" - _inherit = "contract.abstract.contract.line" + _inherit = [ + "contract.abstract.contract.line", + "contract.recurrency.mixin", + ] _order = "sequence,id" sequence = fields.Integer(string="Sequence",) @@ -34,22 +37,8 @@ class ContractLine(models.Model): analytic_tag_ids = fields.Many2many( comodel_name="account.analytic.tag", string="Analytic Tags", ) - date_start = fields.Date( - string="Date Start", - required=True, - default=lambda self: fields.Date.context_today(self), - ) - date_end = fields.Date(string="Date End", index=True) - recurring_next_date = fields.Date(string="Date of Next Invoice") - last_date_invoiced = fields.Date( - string="Last Date Invoiced", readonly=True, copy=False - ) - next_period_date_start = fields.Date( - string="Next Period Start", compute="_compute_next_period_date_start", - ) - next_period_date_end = fields.Date( - string="Next Period End", compute="_compute_next_period_date_end", - ) + date_start = fields.Date(required=True) + date_end = fields.Date(compute="_compute_date_end", store=True, readonly=False) termination_notice_date = fields.Date( string="Termination notice date", compute="_compute_termination_notice_date", @@ -119,6 +108,29 @@ class ContractLine(models.Model): default=True, ) + @api.depends( + "last_date_invoiced", "date_start", "date_end", "contract_id.last_date_invoiced" + ) + def _compute_next_period_date_start(self): + """Rectify next period date start if another line in the contract has been + already invoiced previously. + """ + for rec in self: + lines = rec.contract_id.contract_line_ids + if not rec.last_date_invoiced and any(lines.mapped("last_date_invoiced")): + next_period_date_start = max( + lines.filtered("last_date_invoiced").mapped("last_date_invoiced") + ) + relativedelta(days=1) + if rec.date_end and next_period_date_start > rec.date_end: + next_period_date_start = False + rec.next_period_date_start = next_period_date_start + else: + super(ContractLine, rec)._compute_next_period_date_start() + + @api.depends("contract_id.date_end", "contract_id.line_recurrence") + def _compute_date_end(self): + self._set_recurrence_field("date_end") + @api.depends( "date_end", "termination_notice_rule_type", "termination_notice_interval", ) @@ -389,115 +401,6 @@ class ContractLine(models.Model): max_date_end=False, ) - @api.model - def get_next_invoice_date( - self, - next_period_date_start, - recurring_invoicing_type, - recurring_invoicing_offset, - recurring_rule_type, - recurring_interval, - max_date_end, - ): - next_period_date_end = self.get_next_period_date_end( - next_period_date_start, - recurring_rule_type, - recurring_interval, - max_date_end=max_date_end, - ) - if not next_period_date_end: - return False - if recurring_invoicing_type == "pre-paid": - recurring_next_date = next_period_date_start + relativedelta( - days=recurring_invoicing_offset - ) - else: # post-paid - recurring_next_date = next_period_date_end + relativedelta( - days=recurring_invoicing_offset - ) - return recurring_next_date - - @api.model - def get_next_period_date_end( - self, - next_period_date_start, - recurring_rule_type, - recurring_interval, - max_date_end, - next_invoice_date=False, - recurring_invoicing_type=False, - recurring_invoicing_offset=False, - ): - """Compute the end date for the next period. - - The next period normally depends on recurrence options only. - It is however possible to provide it a next invoice date, in - which case this method can adjust the next period based on that - too. In that scenario it required the invoicing type and offset - arguments. - """ - if not next_period_date_start: - return False - if max_date_end and next_period_date_start > max_date_end: - # start is past max date end: there is no next period - return False - if not next_invoice_date: - # regular algorithm - next_period_date_end = ( - next_period_date_start - + self.get_relative_delta(recurring_rule_type, recurring_interval) - - relativedelta(days=1) - ) - else: - # special algorithm when the next invoice date is forced - if recurring_invoicing_type == "pre-paid": - next_period_date_end = ( - next_invoice_date - - relativedelta(days=recurring_invoicing_offset) - + self.get_relative_delta(recurring_rule_type, recurring_interval) - - relativedelta(days=1) - ) - else: # post-paid - next_period_date_end = next_invoice_date - relativedelta( - days=recurring_invoicing_offset - ) - if max_date_end and next_period_date_end > max_date_end: - # end date is past max_date_end: trim it - next_period_date_end = max_date_end - return next_period_date_end - - @api.depends("last_date_invoiced", "date_start", "date_end") - def _compute_next_period_date_start(self): - for rec in self: - if rec.last_date_invoiced: - next_period_date_start = rec.last_date_invoiced + relativedelta(days=1) - else: - next_period_date_start = rec.date_start - if rec.date_end and next_period_date_start > rec.date_end: - next_period_date_start = False - rec.next_period_date_start = next_period_date_start - - @api.depends( - "next_period_date_start", - "recurring_invoicing_type", - "recurring_invoicing_offset", - "recurring_rule_type", - "recurring_interval", - "date_end", - "recurring_next_date", - ) - def _compute_next_period_date_end(self): - for rec in self: - rec.next_period_date_end = self.get_next_period_date_end( - rec.next_period_date_start, - rec.recurring_rule_type, - rec.recurring_interval, - max_date_end=rec.date_end, - next_invoice_date=rec.recurring_next_date, - recurring_invoicing_type=rec.recurring_invoicing_type, - recurring_invoicing_offset=rec.recurring_invoicing_offset, - ) - @api.model def _get_first_date_end( self, date_start, auto_renew_rule_type, auto_renew_interval @@ -520,24 +423,6 @@ class ContractLine(models.Model): rec.date_start, rec.auto_renew_rule_type, rec.auto_renew_interval, ) - @api.onchange( - "date_start", - "date_end", - "recurring_invoicing_type", - "recurring_rule_type", - "recurring_interval", - ) - def _onchange_date_start(self): - for rec in self.filtered("date_start"): - rec.recurring_next_date = self.get_next_invoice_date( - rec.next_period_date_start, - rec.recurring_invoicing_type, - rec.recurring_invoicing_offset, - rec.recurring_rule_type, - rec.recurring_interval, - max_date_end=rec.date_end, - ) - @api.constrains("is_canceled", "is_auto_renew") def _check_auto_renew_canceled_lines(self): for rec in self: @@ -548,7 +433,9 @@ class ContractLine(models.Model): @api.constrains("recurring_next_date", "date_start") def _check_recurring_next_date_start_date(self): - for line in self.filtered("recurring_next_date"): + for line in self: + if line.display_type == "line_section" or not line.recurring_next_date: + continue if line.date_start and line.recurring_next_date: if line.date_start > line.recurring_next_date: raise ValidationError( @@ -564,14 +451,6 @@ class ContractLine(models.Model): ) def _check_last_date_invoiced(self): for rec in self.filtered("last_date_invoiced"): - if rec.date_start and rec.date_start > rec.last_date_invoiced: - raise ValidationError( - _( - "You can't have the start date after the date of last " - "invoice for the contract line '%s'" - ) - % rec.name - ) if rec.date_end and rec.date_end < rec.last_date_invoiced: raise ValidationError( _( @@ -580,6 +459,16 @@ class ContractLine(models.Model): ) % rec.name ) + if not rec.contract_id.line_recurrence: + continue + if rec.date_start and rec.date_start > rec.last_date_invoiced: + raise ValidationError( + _( + "You can't have the start date after the date of last " + "invoice for the contract line '%s'" + ) + % rec.name + ) if ( rec.recurring_next_date and rec.recurring_next_date <= rec.last_date_invoiced @@ -719,51 +608,6 @@ class ContractLine(models.Model): } ) - def _init_last_date_invoiced(self): - """Used to init last_date_invoiced for migration purpose""" - for rec in self: - last_date_invoiced = rec.recurring_next_date - relativedelta(days=1) - if rec.recurring_rule_type == "monthlylastday": - last_date_invoiced = ( - rec.recurring_next_date - - self.get_relative_delta( - rec.recurring_rule_type, rec.recurring_interval - 1 - ) - - relativedelta(days=1) - ) - elif rec.recurring_invoicing_type == "post-paid": - last_date_invoiced = ( - rec.recurring_next_date - - self.get_relative_delta( - rec.recurring_rule_type, rec.recurring_interval - ) - - relativedelta(days=1) - ) - if last_date_invoiced > rec.date_start: - rec.last_date_invoiced = last_date_invoiced - - @api.model - def get_relative_delta(self, recurring_rule_type, interval): - """Return a relativedelta for one period. - - When added to the first day of the period, - it gives the first day of the next period. - """ - if recurring_rule_type == "daily": - return relativedelta(days=interval) - elif recurring_rule_type == "weekly": - return relativedelta(weeks=interval) - elif recurring_rule_type == "monthly": - return relativedelta(months=interval) - elif recurring_rule_type == "monthlylastday": - return relativedelta(months=interval, day=1) - elif recurring_rule_type == "quarterly": - return relativedelta(months=3 * interval) - elif recurring_rule_type == "semesterly": - return relativedelta(months=6 * interval) - else: - return relativedelta(years=interval) - def _delay(self, delay_delta): """ Delay a contract line @@ -1145,7 +989,6 @@ class ContractLine(models.Model): new_line = self.plan_successor( date_start, date_end, is_auto_renew, post_message=False ) - new_line._onchange_date_start() return new_line def _renew_extend_line(self, date_end): diff --git a/contract/models/contract_recurrency_mixin.py b/contract/models/contract_recurrency_mixin.py new file mode 100644 index 000000000..cbbcd5a11 --- /dev/null +++ b/contract/models/contract_recurrency_mixin.py @@ -0,0 +1,233 @@ +# Copyright 2018 ACSONE SA/NV. +# Copyright 2020 Tecnativa - Pedro M. Baeza +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models + + +class ContractRecurrencyBasicMixin(models.AbstractModel): + _name = "contract.recurrency.basic.mixin" + _description = "Basic recurrency mixin for abstract contract models" + + recurring_rule_type = fields.Selection( + [ + ("daily", "Day(s)"), + ("weekly", "Week(s)"), + ("monthly", "Month(s)"), + ("monthlylastday", "Month(s) last day"), + ("quarterly", "Quarter(s)"), + ("semesterly", "Semester(s)"), + ("yearly", "Year(s)"), + ], + default="monthly", + string="Recurrence", + help="Specify Interval for automatic invoice generation.", + ) + recurring_invoicing_type = fields.Selection( + [("pre-paid", "Pre-paid"), ("post-paid", "Post-paid")], + default="pre-paid", + string="Invoicing type", + help=( + "Specify if the invoice must be generated at the beginning " + "(pre-paid) or end (post-paid) of the period." + ), + ) + recurring_invoicing_offset = fields.Integer( + compute="_compute_recurring_invoicing_offset", + string="Invoicing offset", + help=( + "Number of days to offset the invoice from the period end " + "date (in post-paid mode) or start date (in pre-paid mode)." + ), + ) + recurring_interval = fields.Integer( + default=1, string="Invoice Every", help="Invoice every (Days/Week/Month/Year)", + ) + date_start = fields.Date(string="Date Start") + recurring_next_date = fields.Date(string="Date of Next Invoice") + + @api.depends("recurring_invoicing_type", "recurring_rule_type") + def _compute_recurring_invoicing_offset(self): + for rec in self: + method = self._get_default_recurring_invoicing_offset + rec.recurring_invoicing_offset = method( + rec.recurring_invoicing_type, rec.recurring_rule_type + ) + + @api.model + def _get_default_recurring_invoicing_offset( + self, recurring_invoicing_type, recurring_rule_type + ): + if ( + recurring_invoicing_type == "pre-paid" + or recurring_rule_type == "monthlylastday" + ): + return 0 + else: + return 1 + + +class ContractRecurrencyMixin(models.AbstractModel): + _inherit = "contract.recurrency.basic.mixin" + _name = "contract.recurrency.mixin" + _description = "Recurrency mixin for contract models" + + date_start = fields.Date(default=lambda self: fields.Date.context_today(self)) + recurring_next_date = fields.Date( + compute="_compute_recurring_next_date", store=True, readonly=False, copy=True + ) + date_end = fields.Date(string="Date End", index=True) + next_period_date_start = fields.Date( + string="Next Period Start", compute="_compute_next_period_date_start", + ) + next_period_date_end = fields.Date( + string="Next Period End", compute="_compute_next_period_date_end", + ) + last_date_invoiced = fields.Date( + string="Last Date Invoiced", readonly=True, copy=False + ) + + @api.depends("next_period_date_start") + def _compute_recurring_next_date(self): + for rec in self.filtered("next_period_date_start"): + rec.recurring_next_date = self.get_next_invoice_date( + rec.next_period_date_start, + rec.recurring_invoicing_type, + rec.recurring_invoicing_offset, + rec.recurring_rule_type, + rec.recurring_interval, + max_date_end=rec.date_end, + ) + + @api.depends("last_date_invoiced", "date_start", "date_end") + def _compute_next_period_date_start(self): + for rec in self: + if rec.last_date_invoiced: + next_period_date_start = rec.last_date_invoiced + relativedelta(days=1) + else: + next_period_date_start = rec.date_start + if rec.date_end and next_period_date_start > rec.date_end: + next_period_date_start = False + rec.next_period_date_start = next_period_date_start + + @api.depends( + "next_period_date_start", + "recurring_invoicing_type", + "recurring_invoicing_offset", + "recurring_rule_type", + "recurring_interval", + "date_end", + "recurring_next_date", + ) + def _compute_next_period_date_end(self): + for rec in self: + rec.next_period_date_end = self.get_next_period_date_end( + rec.next_period_date_start, + rec.recurring_rule_type, + rec.recurring_interval, + max_date_end=rec.date_end, + next_invoice_date=rec.recurring_next_date, + recurring_invoicing_type=rec.recurring_invoicing_type, + recurring_invoicing_offset=rec.recurring_invoicing_offset, + ) + + @api.model + def get_relative_delta(self, recurring_rule_type, interval): + """Return a relativedelta for one period. + + When added to the first day of the period, + it gives the first day of the next period. + """ + if recurring_rule_type == "daily": + return relativedelta(days=interval) + elif recurring_rule_type == "weekly": + return relativedelta(weeks=interval) + elif recurring_rule_type == "monthly": + return relativedelta(months=interval) + elif recurring_rule_type == "monthlylastday": + return relativedelta(months=interval, day=1) + elif recurring_rule_type == "quarterly": + return relativedelta(months=3 * interval) + elif recurring_rule_type == "semesterly": + return relativedelta(months=6 * interval) + else: + return relativedelta(years=interval) + + @api.model + def get_next_period_date_end( + self, + next_period_date_start, + recurring_rule_type, + recurring_interval, + max_date_end, + next_invoice_date=False, + recurring_invoicing_type=False, + recurring_invoicing_offset=False, + ): + """Compute the end date for the next period. + + The next period normally depends on recurrence options only. + It is however possible to provide it a next invoice date, in + which case this method can adjust the next period based on that + too. In that scenario it required the invoicing type and offset + arguments. + """ + if not next_period_date_start: + return False + if max_date_end and next_period_date_start > max_date_end: + # start is past max date end: there is no next period + return False + if not next_invoice_date: + # regular algorithm + next_period_date_end = ( + next_period_date_start + + self.get_relative_delta(recurring_rule_type, recurring_interval) + - relativedelta(days=1) + ) + else: + # special algorithm when the next invoice date is forced + if recurring_invoicing_type == "pre-paid": + next_period_date_end = ( + next_invoice_date + - relativedelta(days=recurring_invoicing_offset) + + self.get_relative_delta(recurring_rule_type, recurring_interval) + - relativedelta(days=1) + ) + else: # post-paid + next_period_date_end = next_invoice_date - relativedelta( + days=recurring_invoicing_offset + ) + if max_date_end and next_period_date_end > max_date_end: + # end date is past max_date_end: trim it + next_period_date_end = max_date_end + return next_period_date_end + + @api.model + def get_next_invoice_date( + self, + next_period_date_start, + recurring_invoicing_type, + recurring_invoicing_offset, + recurring_rule_type, + recurring_interval, + max_date_end, + ): + next_period_date_end = self.get_next_period_date_end( + next_period_date_start, + recurring_rule_type, + recurring_interval, + max_date_end=max_date_end, + ) + if not next_period_date_end: + return False + if recurring_invoicing_type == "pre-paid": + recurring_next_date = next_period_date_start + relativedelta( + days=recurring_invoicing_offset + ) + else: # post-paid + recurring_next_date = next_period_date_end + relativedelta( + days=recurring_invoicing_offset + ) + return recurring_next_date diff --git a/contract/readme/ROADMAP.rst b/contract/readme/ROADMAP.rst index 9fcacdf41..0e42fcb53 100644 --- a/contract/readme/ROADMAP.rst +++ b/contract/readme/ROADMAP.rst @@ -1 +1,2 @@ * Recover states and others functional fields in Contracts. +* Add recurrence flag at template level. diff --git a/contract/static/description/index.html b/contract/static/description/index.html index 97cc2b423..a2c439178 100644 --- a/contract/static/description/index.html +++ b/contract/static/description/index.html @@ -425,6 +425,7 @@ To use it, just select the template on the contract and fields will be filled au

Known issues / Roadmap

diff --git a/contract/tests/test_contract.py b/contract/tests/test_contract.py index 22476ddee..8b6b566f1 100644 --- a/contract/tests/test_contract.py +++ b/contract/tests/test_contract.py @@ -9,7 +9,7 @@ from dateutil.relativedelta import relativedelta from odoo import fields from odoo.exceptions import UserError, ValidationError -from odoo.tests import common +from odoo.tests import Form, common def to_date(date): @@ -72,6 +72,7 @@ class TestContractBase(common.SavepointCase): "name": "Test Contract", "partner_id": cls.partner.id, "pricelist_id": cls.partner.property_product_pricelist.id, + "line_recurrence": True, } ) cls.contract2 = cls.env["contract.contract"].create( @@ -79,6 +80,7 @@ class TestContractBase(common.SavepointCase): "name": "Test Contract 2", "partner_id": cls.partner.id, "pricelist_id": cls.partner.property_product_pricelist.id, + "line_recurrence": True, "contract_type": "purchase", "contract_line_ids": [ ( @@ -152,7 +154,7 @@ class TestContract(TestContractBase): self.assertEqual(self.acct_line.price_unit, 10) def test_contract(self): - recurring_next_date = to_date("2018-02-15") + self.assertEqual(self.contract.recurring_next_date, to_date("2018-01-15")) self.assertAlmostEqual(self.acct_line.price_subtotal, 50.0) res = self.acct_line._onchange_product_id() self.assertIn("uom_id", res["domain"]) @@ -161,22 +163,12 @@ class TestContract(TestContractBase): self.contract.recurring_create_invoice() self.invoice_monthly = self.contract._get_related_invoices() self.assertTrue(self.invoice_monthly) - self.assertEqual(self.acct_line.recurring_next_date, recurring_next_date) + self.assertEqual(self.acct_line.recurring_next_date, to_date("2018-02-15")) self.inv_line = self.invoice_monthly.invoice_line_ids[0] self.assertTrue(self.inv_line.tax_ids) self.assertAlmostEqual(self.inv_line.price_subtotal, 50.0) self.assertEqual(self.contract.user_id, self.invoice_monthly.user_id) - def test_contract_recurring_next_date(self): - recurring_next_date = to_date("2018-01-15") - self.assertEqual(self.contract.recurring_next_date, recurring_next_date) - contract_line = self.acct_line.copy({"recurring_next_date": "2018-01-14"}) - recurring_next_date = to_date("2018-01-14") - self.assertEqual(self.contract.recurring_next_date, recurring_next_date) - contract_line.cancel() - recurring_next_date = to_date("2018-01-15") - self.assertEqual(self.contract.recurring_next_date, recurring_next_date) - def test_contract_daily(self): recurring_next_date = to_date("2018-02-23") last_date_invoiced = to_date("2018-02-22") @@ -306,7 +298,6 @@ class TestContract(TestContractBase): 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.acct_line._onchange_date_start() self.assertTrue(self.acct_line.create_invoice_visibility) self.assertEqual(self.acct_line.recurring_next_date, to_date("2018-02-01")) self.assertFalse(self.acct_line.last_date_invoiced) @@ -333,7 +324,6 @@ class TestContract(TestContractBase): self.acct_line.date_start = "2018-01-01" self.acct_line.recurring_invoicing_type = "pre-paid" self.acct_line.date_end = "2018-03-15" - self.acct_line._onchange_date_start() self.assertTrue(self.acct_line.create_invoice_visibility) self.assertEqual(self.acct_line.recurring_next_date, to_date("2018-01-01")) self.assertFalse(self.acct_line.last_date_invoiced) @@ -363,12 +353,6 @@ class TestContract(TestContractBase): self.contract.partner_id.property_product_pricelist, ) - def test_onchange_date_start(self): - recurring_next_date = to_date("2018-01-01") - self.acct_line.date_start = recurring_next_date - self.acct_line._onchange_date_start() - self.assertEqual(self.acct_line.recurring_next_date, recurring_next_date) - def test_uom(self): uom_litre = self.env.ref("uom.product_uom_litre") self.acct_line.uom_id = uom_litre.id @@ -1626,7 +1610,6 @@ class TestContract(TestContractBase): 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.acct_line._onchange_date_start() contracts = self.contract2 for _i in range(10): contracts |= self.contract.copy() @@ -1642,7 +1625,6 @@ class TestContract(TestContractBase): 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.acct_line._onchange_date_start() self.contract2.unlink() contracts = self.contract for _i in range(10): @@ -1674,7 +1656,6 @@ class TestContract(TestContractBase): self.acct_line.recurring_invoicing_type = "post-paid" self.acct_line.recurring_rule_type = "monthlylastday" self.acct_line.date_end = "2018-03-15" - self.acct_line._onchange_date_start() first, last, recurring_next_date = self.acct_line._get_period_to_invoice( self.acct_line.last_date_invoiced, self.acct_line.recurring_next_date, ) @@ -1699,7 +1680,6 @@ class TestContract(TestContractBase): self.acct_line.recurring_invoicing_type = "pre-paid" self.acct_line.recurring_rule_type = "monthlylastday" self.acct_line.date_end = "2018-03-15" - self.acct_line._onchange_date_start() first, last, recurring_next_date = self.acct_line._get_period_to_invoice( self.acct_line.last_date_invoiced, self.acct_line.recurring_next_date, ) @@ -1740,7 +1720,6 @@ class TestContract(TestContractBase): self.acct_line.recurring_invoicing_type = "pre-paid" self.acct_line.recurring_rule_type = "monthly" self.acct_line.date_end = "2018-08-15" - self.acct_line._onchange_date_start() self.contract.recurring_create_invoice() first, last, recurring_next_date = self.acct_line._get_period_to_invoice( self.acct_line.last_date_invoiced, self.acct_line.recurring_next_date, @@ -1759,7 +1738,6 @@ class TestContract(TestContractBase): self.acct_line.recurring_invoicing_type = "post-paid" self.acct_line.recurring_rule_type = "monthly" self.acct_line.date_end = "2018-08-15" - self.acct_line._onchange_date_start() self.contract.recurring_create_invoice() first, last, recurring_next_date = self.acct_line._get_period_to_invoice( self.acct_line.last_date_invoiced, self.acct_line.recurring_next_date, @@ -1778,7 +1756,6 @@ class TestContract(TestContractBase): self.acct_line.recurring_invoicing_type = "post-paid" self.acct_line.recurring_rule_type = "monthly" self.acct_line.date_end = "2018-03-15" - self.acct_line._onchange_date_start() first, last, recurring_next_date = self.acct_line._get_period_to_invoice( self.acct_line.last_date_invoiced, self.acct_line.recurring_next_date, ) @@ -1802,7 +1779,6 @@ class TestContract(TestContractBase): self.acct_line.recurring_invoicing_type = "pre-paid" self.acct_line.recurring_rule_type = "monthly" self.acct_line.date_end = "2018-03-15" - self.acct_line._onchange_date_start() first, last, recurring_next_date = self.acct_line._get_period_to_invoice( self.acct_line.last_date_invoiced, self.acct_line.recurring_next_date, ) @@ -1826,7 +1802,6 @@ class TestContract(TestContractBase): self.acct_line.recurring_invoicing_type = "post-paid" self.acct_line.recurring_rule_type = "yearly" self.acct_line.date_end = "2020-03-15" - self.acct_line._onchange_date_start() first, last, recurring_next_date = self.acct_line._get_period_to_invoice( self.acct_line.last_date_invoiced, self.acct_line.recurring_next_date, ) @@ -1850,7 +1825,6 @@ class TestContract(TestContractBase): self.acct_line.recurring_invoicing_type = "pre-paid" self.acct_line.recurring_rule_type = "yearly" self.acct_line.date_end = "2020-03-15" - self.acct_line._onchange_date_start() first, last, recurring_next_date = self.acct_line._get_period_to_invoice( self.acct_line.last_date_invoiced, self.acct_line.recurring_next_date, ) @@ -2024,30 +1998,6 @@ class TestContract(TestContractBase): {"last_date_invoiced": self.acct_line.date_end + relativedelta(days=1)} ) - def test_init_last_date_invoiced(self): - self.acct_line.write( - {"date_start": "2019-01-01", "recurring_next_date": "2019-03-01"} - ) - line_monthlylastday = self.acct_line.copy( - { - "recurring_rule_type": "monthlylastday", - "recurring_next_date": "2019-03-31", - } - ) - line_prepaid = self.acct_line.copy( - {"recurring_invoicing_type": "pre-paid", "recurring_rule_type": "monthly"} - ) - line_postpaid = self.acct_line.copy( - {"recurring_invoicing_type": "post-paid", "recurring_rule_type": "monthly"} - ) - lines = line_monthlylastday | line_prepaid | line_postpaid - lines.write({"last_date_invoiced": False}) - self.assertFalse(any(lines.mapped("last_date_invoiced"))) - lines._init_last_date_invoiced() - self.assertEqual(line_monthlylastday.last_date_invoiced, to_date("2019-02-28")) - self.assertEqual(line_prepaid.last_date_invoiced, to_date("2019-02-28")) - self.assertEqual(line_postpaid.last_date_invoiced, to_date("2019-01-31")) - def test_delay_invoiced_contract_line(self): self.acct_line.write( {"last_date_invoiced": self.acct_line.date_start + relativedelta(days=1)} @@ -2236,6 +2186,31 @@ class TestContract(TestContractBase): self.terminate_reason, "terminate_comment", to_date("2018-02-13"), ) + def test_recurrency_propagation(self): + # Existing contract + vals = { + "recurring_rule_type": "yearly", + "recurring_interval": 2, + "date_start": to_date("2020-01-01"), + } + vals2 = vals.copy() + vals2["line_recurrence"] = False + self.contract.write(vals2) + for field in vals: + self.assertEqual(vals[field], self.acct_line[field]) + # New contract + contract_form = Form(self.env["contract.contract"]) + contract_form.partner_id = self.partner + contract_form.name = "Test new contract" + contract_form.line_recurrence = False + for field in vals: + setattr(contract_form, field, vals[field]) + with contract_form.contract_line_fixed_ids.new() as line_form: + line_form.product_id = self.product_1 + contract2 = contract_form.save() + for field in vals: + self.assertEqual(vals[field], contract2.contract_line_ids[field]) + def test_currency(self): currency_eur = self.env.ref("base.EUR") currency_cad = self.env.ref("base.CAD") diff --git a/contract/views/contract.xml b/contract/views/contract.xml index a677e3713..927a4a08f 100644 --- a/contract/views/contract.xml +++ b/contract/views/contract.xml @@ -40,7 +40,7 @@