diff --git a/account_spread_cost_revenue/README.rst b/account_spread_cost_revenue/README.rst new file mode 100644 index 00000000..8b6d7adf --- /dev/null +++ b/account_spread_cost_revenue/README.rst @@ -0,0 +1,170 @@ +=================== +Cost-Revenue Spread +=================== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/licence-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%2Faccount--financial--tools-lightgray.png?logo=github + :target: https://github.com/OCA/account-financial-tools/tree/11.0/account_spread_cost_revenue + :alt: OCA/account-financial-tools +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/account-financial-tools-11-0/account-financial-tools-11-0-account_spread_cost_revenue + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/92/11.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Allows to spread costs or revenues over a customizable periods, to even out cost or invoice spikes. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +To be able to access the full spreading features, the user must belong to *Show Full Accounting Features* group. + +On the form view of the company, in the *Account Spread* tab, you can configure +the journals in which the spread journal items will be generated by default: + +* the *Default Spread Journal for Revenues*, +* the *Default Spread Journal for Expenses*. + +In the same *Account Spread* tab, you can also configure the Spread Balance Sheet Accounts used by default: + +* the *Default Spread Account for Revenues*, +* the *Default Spread Account for Expenses*. + +Usage +===== + +Under Invoicing -> Adviser -> Accounting Entries -> Spread Costs/Revenues, create a new spread board. + +Complete the definition of the spreading criteria, by setting the the fields: + +* *Debit Account* +* *Credit Account* +* *Estimated Amount* (The total amount to spread) +* *Number of Repetitions* +* *Period Type* (Duration of each period) +* *Start date* +* *Journal* + +.. figure:: static/description/spread.png + :alt: Create a new spread board + +Click on the button on the top-left to calculate the spread lines. + +.. figure:: static/description/create_spread.png + :alt: The spreading board is defined + +A cron job will automatically create the accounting moves for all the lines having date previous that the current day (today). + +.. figure:: static/description/update_spread.png + :alt: The spreading board is updated by the cron job + +By default, the status of the created accounting moves is unposted, so you should post them manually one by one. +To allow the automatic posting of the accounting moves, set the flag *Auto-post lines* to True. + +Create an invoice or vendor bill in draft. On its lines, the spreading right-arrow icon are displayed in dark-grey color. + +.. figure:: static/description/invoice_line_1.png + :alt: On the invoice line the spreading icon is displayed + +Click on the spreading right-arrow icon. A wizard prompts to enter a *Spread Action Type*: + +- *Link to existing spread board* +- *Create from spread template* +- *Create new spread board* + +Select *Link to existing spread board* and enter the previously generated Spread Board. Click on Confirm button: +the selected Spread Board will be automatically displayed. + +Go back to the draft invoice/bill. The spreading functionality is now enabled on the invoice line: +the spreading right-arrow icon is now displayed in green color. + +.. figure:: static/description/invoice_line_2.png + :alt: On the invoice line the spreading icon is displayed in green color + +Validate the invoice/bill. Click on the spreading (green) right-arrow icon to open the spread board, then click +on the smart button *Reconciled entries*: the moves of the spread lines are reconciled with the move of the invoice line. + +In case the Subtotal Price of the invoice line is different than the *Estimated Amount* of the spread board, the spread +lines (not yet posted) will be recalculated when validating the invoice/bill. + +Click on button *Recalculate entire spread* button in the spread board to force the recalculation of the spread lines: +this will also reset all the journal entries previously created. + +Known issues / Roadmap +====================== + +* Verify last day of month +* Not yet compatible with cutoff module: create module for adaptation? + +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 smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Onestein + +Contributors +~~~~~~~~~~~~ + +* Andrea Stirpe + +Other credits +~~~~~~~~~~~~~ + +Part of the code in this module (in particular the computation of the spread lines) +is highly inspired by the Assets Management module from the standard +Odoo 11.0 Community developed by Odoo SA. + +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. + +.. |maintainer-astirpe| image:: https://github.com/astirpe.png?size=40px + :target: https://github.com/astirpe + :alt: astirpe + +Current `maintainer `__: + +|maintainer-astirpe| + +This module is part of the `OCA/account-financial-tools `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/account_spread_cost_revenue/__init__.py b/account_spread_cost_revenue/__init__.py new file mode 100644 index 00000000..adc6207f --- /dev/null +++ b/account_spread_cost_revenue/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import models +from . import wizards diff --git a/account_spread_cost_revenue/__manifest__.py b/account_spread_cost_revenue/__manifest__.py new file mode 100644 index 00000000..caa3d132 --- /dev/null +++ b/account_spread_cost_revenue/__manifest__.py @@ -0,0 +1,29 @@ +# Copyright 2016-2018 Onestein () +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Cost-Revenue Spread", + "summary": "Spread costs and revenues over a custom period", + "version": "11.0.1.0.0", + "development_status": "Beta", + "author": "Onestein,Odoo Community Association (OCA)", + "maintainers": ["astirpe"], + "license": "AGPL-3", + "website": "https://github.com/OCA/account-financial-tools/", + "category": "Accounting & Finance", + "depends": [ + "account", + ], + "data": [ + "security/ir.model.access.csv", + "security/account_spread_security.xml", + "views/account_spread.xml", + "views/account_invoice.xml", + "views/res_company.xml", + "views/account_spread_template.xml", + "templates/assets.xml", + "wizards/account_spread_invoice_line_link_wizard.xml", + "data/spread_cron.xml", + ], + "installable": True, +} diff --git a/account_spread_cost_revenue/data/spread_cron.xml b/account_spread_cost_revenue/data/spread_cron.xml new file mode 100644 index 00000000..202f14c7 --- /dev/null +++ b/account_spread_cost_revenue/data/spread_cron.xml @@ -0,0 +1,17 @@ + + + + + Cost/revenue Spread: Create Entries + + + 1 + days + -1 + + + code + model._create_entries() + + + diff --git a/account_spread_cost_revenue/models/__init__.py b/account_spread_cost_revenue/models/__init__.py new file mode 100644 index 00000000..e7d567cb --- /dev/null +++ b/account_spread_cost_revenue/models/__init__.py @@ -0,0 +1,8 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import account_invoice +from . import account_invoice_line +from . import account_spread_line +from . import account_spread +from . import account_spread_template +from . import res_company diff --git a/account_spread_cost_revenue/models/account_invoice.py b/account_spread_cost_revenue/models/account_invoice.py new file mode 100644 index 00000000..078844fe --- /dev/null +++ b/account_spread_cost_revenue/models/account_invoice.py @@ -0,0 +1,45 @@ +# Copyright 2016-2018 Onestein () +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, models + + +class AccountInvoice(models.Model): + _inherit = 'account.invoice' + + @api.multi + def action_move_create(self): + """Invoked when validating the invoices.""" + res = super().action_move_create() + for invoice in self: + spreads = invoice.invoice_line_ids.mapped('spread_id') + spreads.compute_spread_board() + spreads.reconcile_spread_moves() + return res + + @api.multi + def invoice_line_move_line_get(self): + """Copying expense/revenue account from spread to move lines.""" + res = super().invoice_line_move_line_get() + for line in res: + invl_id = line.get('invl_id') + invl = self.env['account.invoice.line'].browse(invl_id) + if invl.spread_id: + if invl.invoice_id.type in ('out_invoice', 'in_refund'): + account = invl.spread_id.debit_account_id + else: + account = invl.spread_id.credit_account_id + line['account_id'] = account.id + return res + + @api.multi + def action_cancel(self): + """Cancel the spread lines and their related moves when + the invoice is canceled.""" + res = super().action_cancel() + for invoice_line in self.mapped('invoice_line_ids'): + moves = invoice_line.spread_id.line_ids.mapped('move_id') + moves.button_cancel() + moves.unlink() + invoice_line.spread_id.line_ids.unlink() + return res diff --git a/account_spread_cost_revenue/models/account_invoice_line.py b/account_spread_cost_revenue/models/account_invoice_line.py new file mode 100644 index 00000000..f188e1fc --- /dev/null +++ b/account_spread_cost_revenue/models/account_invoice_line.py @@ -0,0 +1,65 @@ +# Copyright 2016-2018 Onestein () +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models + + +class AccountInvoiceLine(models.Model): + _inherit = 'account.invoice.line' + + spread_id = fields.Many2one( + 'account.spread', + string='Spread Board', + copy=False) + spread_check = fields.Selection([ + ('linked', 'Linked'), + ('unlinked', 'Unlinked'), + ('unavailable', 'Unavailable') + ], compute='_compute_spread_check') + + @api.depends('spread_id', 'invoice_id.state') + def _compute_spread_check(self): + for line in self: + if line.spread_id: + line.spread_check = 'linked' + elif line.invoice_id.state == 'draft': + line.spread_check = 'unlinked' + else: + line.spread_check = 'unavailable' + + @api.multi + def spread_details(self): + """Button on the invoice lines tree view of the invoice + form to show the spread form view.""" + if not self: + # In case the widget clicked before the creation of the line + return + + if self.spread_id: + return { + 'name': _('Spread Details'), + 'view_type': 'form', + 'view_mode': 'form', + 'res_model': 'account.spread', + 'type': 'ir.actions.act_window', + 'target': 'current', + 'readonly': False, + 'res_id': self.spread_id.id, + } + + # In case no spread board is linked to the invoice line + # open the wizard to link them + ctx = dict( + self.env.context, + default_invoice_line_id=self.id, + default_company_id=self.invoice_id.company_id.id, + ) + return { + 'name': _('Link Invoice Line with Spread Board'), + 'view_type': 'form', + 'view_mode': 'form', + 'res_model': 'account.spread.invoice.line.link.wizard', + 'type': 'ir.actions.act_window', + 'target': 'new', + 'context': ctx, + } diff --git a/account_spread_cost_revenue/models/account_spread.py b/account_spread_cost_revenue/models/account_spread.py new file mode 100644 index 00000000..c196ea33 --- /dev/null +++ b/account_spread_cost_revenue/models/account_spread.py @@ -0,0 +1,461 @@ +# Copyright 2018 Onestein () +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import calendar +import time + +from dateutil.relativedelta import relativedelta + +from odoo import _, api, fields, models +from odoo.addons import decimal_precision as dp +from odoo.exceptions import UserError, ValidationError +from odoo.tools import float_is_zero + + +class AccountSpread(models.Model): + _name = 'account.spread' + _description = 'Account Spread' + _inherit = ['mail.thread'] + + name = fields.Char(required=True) + template_id = fields.Many2one( + 'account.spread.template', + string='Spread Template') + invoice_type = fields.Selection([ + ('out_invoice', 'Customer Invoice'), + ('in_invoice', 'Vendor Bill'), + ('out_refund', 'Customer Credit Note'), + ('in_refund', 'Vendor Credit Note')], + required=True) + spread_type = fields.Selection([ + ('sale', 'Customer'), + ('purchase', 'Supplier')], + compute='_compute_spread_type', + required=True) + period_number = fields.Integer( + string='Number of Repetitions', + default=12, + help="Define the number of spread lines", + required=True) + period_type = fields.Selection([ + ('month', 'Month'), + ('quarter', 'Quarter'), + ('year', 'Year')], + default='month', + help="Period length for the entries", + required=True) + credit_account_id = fields.Many2one( + 'account.account', + string='Credit Account', + required=True) + debit_account_id = fields.Many2one( + 'account.account', + string='Debit Account', + required=True) + is_credit_account_deprecated = fields.Boolean( + compute='_compute_deprecated_accounts') + is_debit_account_deprecated = fields.Boolean( + compute='_compute_deprecated_accounts') + unspread_amount = fields.Float( + digits=dp.get_precision('Account'), + compute='_compute_amounts') + unposted_amount = fields.Float( + digits=dp.get_precision('Account'), + compute='_compute_amounts') + posted_amount = fields.Float( + digits=dp.get_precision('Account'), + compute='_compute_amounts') + total_amount = fields.Float( + digits=dp.get_precision('Account'), + compute='_compute_amounts') + line_ids = fields.One2many( + 'account.spread.line', + 'spread_id', + string='Spread Lines') + spread_date = fields.Date( + string='Start Date', + default=time.strftime('%Y-01-01'), + required=True) + journal_id = fields.Many2one( + 'account.journal', + string='Journal', + required=True) + invoice_line_ids = fields.One2many( + 'account.invoice.line', + 'spread_id', + copy=False, + string='Invoice Lines') + invoice_line_id = fields.Many2one( + 'account.invoice.line', + string='Invoice line', + compute='_compute_invoice_line', + inverse='_inverse_invoice_line', + store=True) + invoice_id = fields.Many2one( + related='invoice_line_id.invoice_id', + readonly=True, + store=True, + string='Invoice') + estimated_amount = fields.Float(digits=dp.get_precision('Account')) + company_id = fields.Many2one( + 'res.company', + default=lambda self: self.env.user.company_id, + string='Company', + required=True) + currency_id = fields.Many2one( + 'res.currency', + string='Currency', + required=True, + default=lambda self: self.env.user.company_id.currency_id.id) + account_analytic_id = fields.Many2one( + 'account.analytic.account', + string='Analytic Account') + analytic_tag_ids = fields.Many2many( + 'account.analytic.tag', + string='Analytic Tags') + move_line_auto_post = fields.Boolean('Auto-post lines', default=True) + + @api.model + def default_get(self, fields): + res = super().default_get(fields) + if 'company_id' not in fields: + company_id = self.env.user.company_id.id + else: + company_id = res['company_id'] + default_journal = self.env['account.journal'].search([ + ('type', '=', 'general'), + ('company_id', '=', company_id) + ], limit=1) + if 'journal_id' not in res and default_journal: + res['journal_id'] = default_journal.id + return res + + @api.depends('invoice_type') + def _compute_spread_type(self): + for spread in self: + if spread.invoice_type in ['out_invoice', 'out_refund']: + spread.spread_type = 'sale' + else: + spread.spread_type = 'purchase' + + @api.depends('invoice_line_ids', 'invoice_line_ids.invoice_id') + def _compute_invoice_line(self): + for spread in self: + invoice_lines = spread.invoice_line_ids + line = invoice_lines and invoice_lines[0] or False + spread.invoice_line_id = line + + @api.multi + def _inverse_invoice_line(self): + for spread in self: + invoice_line = spread.invoice_line_id + spread.write({ + 'invoice_line_ids': [(6, 0, [invoice_line.id])], + }) + + @api.depends( + 'estimated_amount', 'invoice_line_id.price_subtotal', + 'line_ids.move_id.amount', 'line_ids.move_id.state') + def _compute_amounts(self): + for spread in self: + moves_amount = 0.0 + posted_amount = 0.0 + total_amount = spread.estimated_amount + if spread.invoice_line_id: + total_amount = spread.invoice_line_id.price_subtotal + for spread_line in spread.line_ids: + if spread_line.move_id: + moves_amount += spread_line.amount + if spread_line.move_id.state == 'posted': + posted_amount += spread_line.amount + spread.unspread_amount = total_amount - moves_amount + spread.unposted_amount = total_amount - posted_amount + spread.posted_amount = posted_amount + spread.total_amount = total_amount + + @api.multi + def _get_spread_entry_name(self, seq): + """Use this method to customise the name of the accounting entry.""" + self.ensure_one() + return (self.name or '') + '/' + str(seq) + + @api.onchange('template_id') + def onchange_template(self): + if self.template_id: + if self.template_id.spread_type == 'sale': + if self.invoice_type in ['in_invoice', 'in_refund']: + self.invoice_type = 'out_invoice' + else: + if self.invoice_type in ['out_invoice', 'out_refund']: + self.invoice_type = 'in_invoice' + + @api.onchange('invoice_type', 'company_id') + def onchange_invoice_type(self): + company = self.company_id + if not self.env.context.get('default_journal_id'): + journal = company.default_spread_expense_journal_id + if self.invoice_type in ('out_invoice', 'in_refund'): + journal = company.default_spread_revenue_journal_id + if journal: + self.journal_id = journal + + if not self.env.context.get('default_debit_account_id'): + if self.invoice_type in ('out_invoice', 'in_refund'): + debit_account_id = company.default_spread_revenue_account_id + self.debit_account_id = debit_account_id + + if not self.env.context.get('default_credit_account_id'): + if self.invoice_type in ('in_invoice', 'out_refund'): + credit_account_id = company.default_spread_expense_account_id + self.credit_account_id = credit_account_id + + @api.constrains('invoice_id', 'invoice_type') + def _check_invoice_type(self): + for spread in self: + if not spread.invoice_id: + pass + elif spread.invoice_type != spread.invoice_id.type: + raise ValidationError(_( + 'The Invoice Type does not correspond to the Invoice')) + + @api.constrains('journal_id') + def _check_journal(self): + for spread in self: + moves = spread.mapped('line_ids.move_id').filtered('journal_id') + if any(move.journal_id != spread.journal_id for move in moves): + raise ValidationError(_( + 'The Journal is not consistent with the account moves.')) + + @api.constrains('template_id', 'invoice_type') + def _check_template_invoice_type(self): + for spread in self: + if spread.invoice_type in ['in_invoice', 'in_refund']: + if spread.template_id.spread_type == 'sale': + raise ValidationError(_( + 'The Spread Template (Sales) is not compatible ' + 'with selected invoice type')) + elif spread.invoice_type in ['out_invoice', 'out_refund']: + if spread.template_id.spread_type == 'purchase': + raise ValidationError(_( + 'The Spread Template (Purchases) is not compatible ' + 'with selected invoice type')) + + @api.multi + def _compute_spread_period_duration(self): + """Converts the selected period_type to number of months.""" + self.ensure_one() + if self.period_type == 'year': + return 12 + elif self.period_type == 'quarter': + return 3 + return 1 + + @api.multi + def _init_line_date(self, posted_line_ids): + """Calculates the initial spread date. This method + is used by "def _compute_spread_board()" method. + """ + self.ensure_one() + if posted_line_ids: + # if we already have some previous validated entries, + # starting date is last entry + method period + last_date = fields.Date.from_string(posted_line_ids[-1].date) + months = self._compute_spread_period_duration() + spread_date = last_date + relativedelta(months=months) + else: + spread_date = fields.Date.from_string(self.spread_date) + return spread_date + + @api.multi + def _next_line_date(self, month_day, date): + """Calculates the next spread date. This method + is used by "def _compute_spread_board()" method. + """ + self.ensure_one() + months = self._compute_spread_period_duration() + date = date + relativedelta(months=months) + # get the last day of the month + if month_day > 28: + max_day_in_month = calendar.monthrange(date.year, date.month)[1] + date = date.replace(day=min(max_day_in_month, month_day)) + return date + + @api.multi + def _compute_spread_board(self): + """Creates the spread lines. This method is highly inspired + from method compute_depreciation_board() present in standard + "account_asset" module, developed by Odoo SA. + """ + self.ensure_one() + + posted_line_ids = self.line_ids.filtered( + lambda x: x.move_id.state == 'posted').sorted( + key=lambda l: l.date) + unposted_line_ids = self.line_ids.filtered( + lambda x: not x.move_id.state == 'posted') + + # Remove old unposted spread lines. + commands = [(2, line_id.id, False) for line_id in unposted_line_ids] + + if self.unposted_amount != 0.0: + unposted_amount = self.unposted_amount + + spread_date = self._init_line_date(posted_line_ids) + + month_day = spread_date.day + number_of_periods = self._get_number_of_periods(month_day) + + for x in range(len(posted_line_ids), number_of_periods): + sequence = x + 1 + amount = self._compute_board_amount( + sequence, unposted_amount, number_of_periods + ) + amount = self.currency_id.round(amount) + rounding = self.currency_id.rounding + if float_is_zero(amount, precision_rounding=rounding): + continue + unposted_amount -= amount + vals = { + 'amount': amount, + 'spread_id': self.id, + 'name': self._get_spread_entry_name(sequence), + 'date': self._get_last_day_of_month(spread_date), + } + commands.append((0, False, vals)) + + spread_date = self._next_line_date(month_day, spread_date) + + self.write({'line_ids': commands}) + self.message_post(body=_("Spread table created.")) + + @api.multi + def _get_number_of_periods(self, month_day): + """Calculates the number of spread lines.""" + self.ensure_one() + if month_day != 1: + return self.period_number + 1 + return self.period_number + + @staticmethod + def _get_last_day_of_month(spread_date): + return fields.Date.to_string(spread_date + relativedelta(day=31)) + + @api.multi + def _compute_board_amount(self, sequence, amount, number_of_periods): + """Calculates the amount for the spread lines.""" + self.ensure_one() + amount_to_spread = self.total_amount + if sequence != number_of_periods: + amount = amount_to_spread / self.period_number + if sequence == 1: + date = fields.Datetime.from_string(self.spread_date) + month_days = calendar.monthrange(date.year, date.month)[1] + days = month_days - date.day + 1 + period = self.period_number + amount = (amount_to_spread / period) / month_days * days + return amount + + @api.multi + def compute_spread_board(self): + """Checks whether the spread lines should be calculated. + In case checks pass, invoke "def _compute_spread_board()" method. + """ + for spread in self: + if spread.total_amount < 0.0: + raise UserError( + _("Cannot spread negative amounts of invoice lines")) + if spread.total_amount: + spread._compute_spread_board() + + @api.multi + def action_recalculate_spread(self): + """Recalculate spread""" + self.ensure_one() + spread_lines = self.mapped('line_ids').filtered('move_id') + spread_lines.unlink_move() + self.compute_spread_board() + self.env['account.spread.line']._create_entries() + + @api.multi + def action_undo_spread(self): + """Undo spreading: Remove all created moves, + restore original account on move line""" + self.ensure_one() + self.mapped('line_ids').filtered('move_id').unlink_move() + self.mapped('line_ids').unlink() + + @api.multi + def action_unlink_invoice_line(self): + """Unlink the invoice line from the spread board""" + self.ensure_one() + if self.invoice_id.state != 'draft': + raise UserError( + _("Cannot unlink invoice lines if the invoice is validated")) + self._action_unlink_invoice_line() + + @api.multi + def _action_unlink_invoice_line(self): + for spread in self: + spread_mls = spread.line_ids.mapped('move_id.line_ids') + spread_mls.remove_move_reconcile() + spread.write({'invoice_line_ids': [(5, 0, 0)]}) + + @api.multi + def reconcile_spread_moves(self): + for spread in self: + spread._reconcile_spread_moves() + + @api.multi + def _reconcile_spread_moves(self, created_moves=False): + """Reconcile spread moves if possible""" + self.ensure_one() + + if not self.invoice_id.number: + return + + spread_mls = self.line_ids.mapped('move_id.line_ids') + if created_moves: + spread_mls |= created_moves.mapped('line_ids') + if self.invoice_type in ('in_invoice', 'out_refund'): + spread_mls = spread_mls.filtered(lambda x: x.credit != 0.) + else: + spread_mls = spread_mls.filtered(lambda x: x.debit != 0.) + + invoice_mls = self.invoice_id.move_id.mapped('line_ids') + if self.invoice_id.type in ('in_invoice', 'out_refund'): + invoice_mls = invoice_mls.filtered(lambda x: x.debit != 0.) + else: + invoice_mls = invoice_mls.filtered(lambda x: x.credit != 0.) + + to_be_reconciled = self.env['account.move.line'] + if len(invoice_mls) > 1: + # Refine selection of move line. + # The name is formatted the same way as it is done when creating + # move lines in method "def invoice_line_move_line_get()" of + # standard account module + raw_name = self.invoice_line_id.name + formatted_name = raw_name.split('\n')[0][:64] + for move_line in invoice_mls: + if move_line.name == formatted_name: + to_be_reconciled |= move_line + else: + to_be_reconciled = invoice_mls + + if len(to_be_reconciled) == 1: + do_reconcile = spread_mls + to_be_reconciled + do_reconcile.remove_move_reconcile() + do_reconcile.reconcile() + + @api.multi + def _compute_deprecated_accounts(self): + for spread in self: + debit_deprecated = bool(spread.debit_account_id.deprecated) + credit_deprecated = bool(spread.credit_account_id.deprecated) + spread.is_debit_account_deprecated = debit_deprecated + spread.is_credit_account_deprecated = credit_deprecated + + @api.multi + def open_reconcile_view(self): + self.ensure_one() + spread_mls = self.line_ids.mapped('move_id.line_ids') + return spread_mls.open_reconcile_view() diff --git a/account_spread_cost_revenue/models/account_spread_line.py b/account_spread_cost_revenue/models/account_spread_line.py new file mode 100644 index 00000000..47ba0656 --- /dev/null +++ b/account_spread_cost_revenue/models/account_spread_line.py @@ -0,0 +1,150 @@ +# Copyright 2016-2018 Onestein () +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.addons import decimal_precision as dp +from odoo.exceptions import UserError + + +class AccountInvoiceSpreadLine(models.Model): + _name = 'account.spread.line' + _description = 'Account Spread Lines' + _order = 'date' + + name = fields.Char('Description', readonly=True) + amount = fields.Float(digits=dp.get_precision('Account'), required=True) + date = fields.Date(required=True) + spread_id = fields.Many2one( + 'account.spread', string='Spread Board', ondelete='cascade') + move_id = fields.Many2one( + 'account.move', string='Journal Entry', readonly=True) + + @api.multi + def create_and_reconcile_moves(self): + grouped_lines = {} + for spread_line in self: + spread = spread_line.spread_id + spread_line_list = grouped_lines.get( + spread, self.env['account.spread.line']) + grouped_lines.update({ + spread: spread_line_list + spread_line + }) + for spread in grouped_lines: + created_moves = grouped_lines[spread]._create_moves() + + if created_moves: + post_msg = _("Created move(s) ") + post_msg += ", ".join(str(id) for id in created_moves.ids) + spread.message_post(body=post_msg) + + spread._reconcile_spread_moves(created_moves) + if created_moves and spread.move_line_auto_post: + created_moves.post() + + @api.multi + def create_move(self): + """Button to manually create a move from a spread line entry. + """ + self.ensure_one() + self.create_and_reconcile_moves() + + @api.multi + def _create_moves(self): + created_moves = self.env['account.move'] + for line in self: + if line.move_id: + raise UserError(_('This spread line is already linked to a ' + 'journal entry! Please post or delete it.')) + + move_vals = line._prepare_move() + move = self.env['account.move'].create(move_vals) + + line.write({'move_id': move.id}) + created_moves += move + return created_moves + + @api.multi + def _prepare_move(self): + self.ensure_one() + + spread_date = self.env.context.get('spread_date') or self.date + spread = self.spread_id + analytic = spread.account_analytic_id + analytic_tags = [(4, tag.id, None) for tag in spread.analytic_tag_ids] + + company_currency = spread.company_id.currency_id + current_currency = spread.currency_id + not_same_curr = company_currency != current_currency + amount = current_currency.with_context(date=spread_date).compute( + self.amount, company_currency) + + line_ids = [(0, 0, { + 'name': spread.name.split('\n')[0][:64], + 'account_id': spread.debit_account_id.id, + 'debit': amount, + 'credit': 0.0, + 'analytic_account_id': analytic.id, + 'analytic_tag_ids': analytic_tags, + 'currency_id': not_same_curr and current_currency.id or False, + 'amount_currency': not_same_curr and - 1.0 * self.amount or 0.0, + }), (0, 0, { + 'name': spread.name.split('\n')[0][:64], + 'account_id': spread.credit_account_id.id, + 'credit': amount, + 'debit': 0.0, + 'analytic_account_id': analytic.id, + 'analytic_tag_ids': analytic_tags, + 'currency_id': not_same_curr and current_currency.id or False, + 'amount_currency': not_same_curr and self.amount or 0.0, + })] + + return { + 'name': self.spread_id and self.spread_id.name or "/", + 'ref': self.name, + 'date': spread_date, + 'journal_id': spread.journal_id.id, + 'line_ids': line_ids, + 'company_id': spread.company_id.id, + } + + @api.multi + def open_move(self): + """Used by a button to manually view a move from a + spread line entry. + """ + self.ensure_one() + return { + 'name': _("Journal Entry"), + 'view_type': 'form', + 'view_mode': 'form', + 'res_model': 'account.move', + 'view_id': False, + 'type': 'ir.actions.act_window', + 'res_id': self.move_id.id, + } + + @api.multi + def unlink_move(self): + """Used by a button to manually unlink a move + from a spread line entry. + """ + for line in self: + move = line.move_id + if move.state == 'posted': + move.button_cancel() + move.line_ids.remove_move_reconcile() + post_msg = _("Deleted move %s") % line.move_id.id + move.unlink() + line.move_id = False + line.spread_id.message_post(body=post_msg) + + @api.model + def _create_entries(self): + """Find spread line entries where date is in the past and + create moves for them. Method also called by the cron job. + """ + lines = self.search([ + ('date', '<=', fields.Date.today()), + ('move_id', '=', False) + ]) + lines.create_and_reconcile_moves() diff --git a/account_spread_cost_revenue/models/account_spread_template.py b/account_spread_cost_revenue/models/account_spread_template.py new file mode 100644 index 00000000..530e6367 --- /dev/null +++ b/account_spread_cost_revenue/models/account_spread_template.py @@ -0,0 +1,78 @@ +# Copyright 2018 Onestein () +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class AccountSpreadTemplate(models.Model): + _name = 'account.spread.template' + _description = 'Account Spread Template' + + name = fields.Char(required=True) + spread_type = fields.Selection([ + ('sale', 'Customer'), + ('purchase', 'Supplier')], + default='sale', + required=True) + company_id = fields.Many2one( + 'res.company', + default=lambda self: self.env.user.company_id, + string='Company', + required=True) + spread_journal_id = fields.Many2one( + 'account.journal', + string='Journal', + required=True) + spread_account_id = fields.Many2one( + 'account.account', + string='Spread Balance Sheet Account', + required=True) + + @api.model + def default_get(self, fields): + res = super().default_get(fields) + if 'company_id' not in fields: + company_id = self.env.user.company_id.id + else: + company_id = res['company_id'] + default_journal = self.env['account.journal'].search([ + ('type', '=', 'general'), + ('company_id', '=', company_id)], + limit=1) + if 'spread_journal_id' not in res and default_journal: + res['spread_journal_id'] = default_journal.id + return res + + @api.onchange('spread_type', 'company_id') + def onchange_spread_type(self): + company = self.company_id + if self.spread_type == 'sale': + account = company.default_spread_revenue_account_id + journal = company.default_spread_revenue_journal_id + else: + account = company.default_spread_expense_account_id + journal = company.default_spread_expense_journal_id + if account: + self.spread_account_id = account + if journal: + self.spread_journal_id = journal + + def _prepare_spread_from_template(self): + self.ensure_one() + company = self.company_id + spread_vals = { + 'name': self.name, + 'template_id': self.id, + 'journal_id': self.spread_journal_id.id, + 'company_id': company.id, + } + + if self.spread_type == 'sale': + invoice_type = 'out_invoice' + spread_vals['debit_account_id'] = self.spread_account_id.id + else: + invoice_type = 'in_invoice' + spread_vals['credit_account_id'] = self.spread_account_id.id + + spread_vals['invoice_type'] = invoice_type + return spread_vals diff --git a/account_spread_cost_revenue/models/res_company.py b/account_spread_cost_revenue/models/res_company.py new file mode 100644 index 00000000..edd7207c --- /dev/null +++ b/account_spread_cost_revenue/models/res_company.py @@ -0,0 +1,20 @@ +# Copyright 2018 Onestein () +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = 'res.company' + + default_spread_revenue_account_id = fields.Many2one( + 'account.account', string='Revenue Spread Account') + + default_spread_expense_account_id = fields.Many2one( + 'account.account', string='Expense Spread Account') + + default_spread_revenue_journal_id = fields.Many2one( + 'account.journal', string='Revenue Spread Journal') + + default_spread_expense_journal_id = fields.Many2one( + 'account.journal', string='Expense Spread Journal') diff --git a/account_spread_cost_revenue/readme/CONFIGURE.rst b/account_spread_cost_revenue/readme/CONFIGURE.rst new file mode 100644 index 00000000..9550b10b --- /dev/null +++ b/account_spread_cost_revenue/readme/CONFIGURE.rst @@ -0,0 +1,12 @@ +To be able to access the full spreading features, the user must belong to *Show Full Accounting Features* group. + +On the form view of the company, in the *Account Spread* tab, you can configure +the journals in which the spread journal items will be generated by default: + +* the *Default Spread Journal for Revenues*, +* the *Default Spread Journal for Expenses*. + +In the same *Account Spread* tab, you can also configure the Spread Balance Sheet Accounts used by default: + +* the *Default Spread Account for Revenues*, +* the *Default Spread Account for Expenses*. diff --git a/account_spread_cost_revenue/readme/CONTRIBUTORS.rst b/account_spread_cost_revenue/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000..4518218c --- /dev/null +++ b/account_spread_cost_revenue/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Andrea Stirpe diff --git a/account_spread_cost_revenue/readme/CREDITS.rst b/account_spread_cost_revenue/readme/CREDITS.rst new file mode 100644 index 00000000..9a3666a4 --- /dev/null +++ b/account_spread_cost_revenue/readme/CREDITS.rst @@ -0,0 +1,3 @@ +Part of the code in this module (in particular the computation of the spread lines) +is highly inspired by the Assets Management module from the standard +Odoo 11.0 Community developed by Odoo SA. diff --git a/account_spread_cost_revenue/readme/DESCRIPTION.rst b/account_spread_cost_revenue/readme/DESCRIPTION.rst new file mode 100644 index 00000000..d37dce99 --- /dev/null +++ b/account_spread_cost_revenue/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +Allows to spread costs or revenues over a customizable periods, to even out cost or invoice spikes. diff --git a/account_spread_cost_revenue/readme/HISTORY.rst b/account_spread_cost_revenue/readme/HISTORY.rst new file mode 100644 index 00000000..07877aaf --- /dev/null +++ b/account_spread_cost_revenue/readme/HISTORY.rst @@ -0,0 +1,5 @@ +11.0.1.0.0 +~~~~~~~~~~ + +* [ADD] Module account_spread_cost_revenue. + (`#715 `_) diff --git a/account_spread_cost_revenue/readme/ROADMAP.rst b/account_spread_cost_revenue/readme/ROADMAP.rst new file mode 100644 index 00000000..2999cff1 --- /dev/null +++ b/account_spread_cost_revenue/readme/ROADMAP.rst @@ -0,0 +1,2 @@ +* Verify last day of month +* Add help in fields definition diff --git a/account_spread_cost_revenue/readme/USAGE.rst b/account_spread_cost_revenue/readme/USAGE.rst new file mode 100644 index 00000000..01b54162 --- /dev/null +++ b/account_spread_cost_revenue/readme/USAGE.rst @@ -0,0 +1,74 @@ +Define Spread Costs/Revenues Board +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Under Invoicing -> Adviser -> Accounting Entries -> Spread Costs/Revenues, create a new spread board. + +Complete the definition of the spreading criteria, by setting the the fields: + +* *Debit Account* +* *Credit Account* +* *Estimated Amount* (The total amount to spread) +* *Number of Repetitions* +* *Period Type* (Duration of each period) +* *Start date* +* *Journal* + +.. figure:: https://raw.githubusercontent.com/OCA/account-financial-tools/11.0/account_spread_cost_revenue/static/description/spread.png + :alt: Create a new spread board + +Click on the button on the top-left to calculate the spread lines. + +.. figure:: https://raw.githubusercontent.com/OCA/account-financial-tools/11.0/account_spread_cost_revenue/static/description/create_spread.png + :alt: The spreading board is defined + +A cron job will automatically create the accounting moves for all the lines having date previous that the current day (today). + +.. figure:: https://raw.githubusercontent.com/OCA/account-financial-tools/11.0/account_spread_cost_revenue/static/description/update_spread.png + :alt: The spreading board is updated by the cron job + +By default, the status of the created accounting moves is posted. +To disable the automatic posting of the accounting moves, set the flag *Auto-post lines* to False. + +Click on button *Recalculate entire spread* button in the spread board to force the recalculation of the spread lines: +this will also reset all the journal entries previously created. + +Link Invoice to Spread Costs/Revenues Board +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Create an invoice or vendor bill in draft. On its lines, the spreading right-arrow icon are displayed in dark-grey color. + +.. figure:: https://raw.githubusercontent.com/OCA/account-financial-tools/11.0/account_spread_cost_revenue/static/description/invoice_line_1.png + :alt: On the invoice line the spreading icon is displayed + +Click on the spreading right-arrow icon. A wizard prompts to enter a *Spread Action Type*: + +- *Link to existing spread board* +- *Create from spread template* +- *Create new spread board* + +Select *Link to existing spread board* and enter the previously generated Spread Board. Click on Confirm button: +the selected Spread Board will be automatically displayed. + +Go back to the draft invoice/bill. The spreading functionality is now enabled on the invoice line: +the spreading right-arrow icon is now displayed in green color. + +.. figure:: https://raw.githubusercontent.com/OCA/account-financial-tools/11.0/account_spread_cost_revenue/static/description/invoice_line_2.png + :alt: On the invoice line the spreading icon is displayed in green color + +Validate the invoice/bill. Click on the spreading (green) right-arrow icon to open the spread board, then click +on the smart button *Reconciled entries*: the moves of the spread lines are reconciled with the move of the invoice line. + +In case the Subtotal Price of the invoice line is different than the *Estimated Amount* of the spread board, the spread +lines (not yet posted) will be recalculated when validating the invoice/bill. + +Define Spread Costs/Revenues Template +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Under Invoicing -> Configuration -> Accounting -> Spread Templates, create a new spread template. + +* *Spread Type* +* *Spread Balance Sheet Account* +* *Journal* + +When creating a new Spread Costs/Revenues Board, select the right template. +This way the above fields will be copied to the Spread Board. diff --git a/account_spread_cost_revenue/security/account_spread_security.xml b/account_spread_cost_revenue/security/account_spread_security.xml new file mode 100644 index 00000000..15e1b675 --- /dev/null +++ b/account_spread_cost_revenue/security/account_spread_security.xml @@ -0,0 +1,13 @@ + + + + + + Account Spread multi-company + + + ['|',('company_id','=',False),('company_id','child_of',[user.company_id.id])] + + + + diff --git a/account_spread_cost_revenue/security/ir.model.access.csv b/account_spread_cost_revenue/security/ir.model.access.csv new file mode 100644 index 00000000..a4e7d04a --- /dev/null +++ b/account_spread_cost_revenue/security/ir.model.access.csv @@ -0,0 +1,7 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_account_spread_cost_revenue_full,Full access on account.spread,model_account_spread,account.group_account_manager,1,1,1,1 +access_account_spread_cost_revenue_read,Read access on account.spread,model_account_spread,account.group_account_user,1,0,0,0 +access_account_spread_cost_revenue_line_full,Full access on account.spread.line,model_account_spread_line,account.group_account_manager,1,1,1,1 +access_account_spread_cost_revenue_line_read,Read access on account.spread.line,model_account_spread_line,account.group_account_user,1,0,0,0 +access_account_spread_cost_revenue_template_full,Full access on account.spread.template,model_account_spread_template,account.group_account_manager,1,1,1,1 +access_account_spread_cost_revenue_template_read,Read access on account.spread.template,model_account_spread_template,account.group_account_user,1,0,0,0 diff --git a/account_spread_cost_revenue/static/description/create_spread.png b/account_spread_cost_revenue/static/description/create_spread.png new file mode 100644 index 00000000..27bb3c3e Binary files /dev/null and b/account_spread_cost_revenue/static/description/create_spread.png differ diff --git a/account_spread_cost_revenue/static/description/icon.png b/account_spread_cost_revenue/static/description/icon.png new file mode 100644 index 00000000..e23c3eb5 Binary files /dev/null and b/account_spread_cost_revenue/static/description/icon.png differ diff --git a/account_spread_cost_revenue/static/description/index.html b/account_spread_cost_revenue/static/description/index.html new file mode 100644 index 00000000..01247ace --- /dev/null +++ b/account_spread_cost_revenue/static/description/index.html @@ -0,0 +1,503 @@ + + + + + + +Cost-Revenue Spread + + + +
+

Cost-Revenue Spread

+ + +

Beta License: AGPL-3 OCA/account-financial-tools Translate me on Weblate Try me on Runbot

+

Allows to spread costs or revenues over a customizable periods, to even out cost or invoice spikes.

+

Table of contents

+ +
+

Configuration

+

To be able to access the full spreading features, the user must belong to Show Full Accounting Features group.

+

On the form view of the company, in the Account Spread tab, you can configure +the journals in which the spread journal items will be generated by default:

+
    +
  • the Default Spread Journal for Revenues,
  • +
  • the Default Spread Journal for Expenses.
  • +
+

In the same Account Spread tab, you can also configure the Spread Balance Sheet Accounts used by default:

+
    +
  • the Default Spread Account for Revenues,
  • +
  • the Default Spread Account for Expenses.
  • +
+
+
+

Usage

+

Under Invoicing -> Adviser -> Accounting Entries -> Spread Costs/Revenues, create a new spread board.

+

Complete the definition of the spreading criteria, by setting the the fields:

+
    +
  • Debit Account
  • +
  • Credit Account
  • +
  • Estimated Amount (The total amount to spread)
  • +
  • Number of Repetitions
  • +
  • Period Type (Duration of each period)
  • +
  • Start date
  • +
  • Journal
  • +
+
+Create a new spread board +
+

Click on the button on the top-left to calculate the spread lines.

+
+The spreading board is defined +
+

A cron job will automatically create the accounting moves for all the lines having date previous that the current day (today).

+
+The spreading board is updated by the cron job +
+

By default, the status of the created accounting moves is unposted, so you should post them manually one by one. +To allow the automatic posting of the accounting moves, set the flag Auto-post lines to True.

+

Create an invoice or vendor bill in draft. On its lines, the spreading right-arrow icon are displayed in dark-grey color.

+
+On the invoice line the spreading icon is displayed +
+

Click on the spreading right-arrow icon. A wizard prompts to enter a Spread Action Type:

+
    +
  • Link to existing spread board
  • +
  • Create from spread template
  • +
  • Create new spread board
  • +
+

Select Link to existing spread board and enter the previously generated Spread Board. Click on Confirm button: +the selected Spread Board will be automatically displayed.

+

Go back to the draft invoice/bill. The spreading functionality is now enabled on the invoice line: +the spreading right-arrow icon is now displayed in green color.

+
+On the invoice line the spreading icon is displayed in green color +
+

Validate the invoice/bill. Click on the spreading (green) right-arrow icon to open the spread board, then click +on the smart button Reconciled entries: the moves of the spread lines are reconciled with the move of the invoice line.

+

In case the Subtotal Price of the invoice line is different than the Estimated Amount of the spread board, the spread +lines (not yet posted) will be recalculated when validating the invoice/bill.

+

Click on button Recalculate entire spread button in the spread board to force the recalculation of the spread lines: +this will also reset all the journal entries previously created.

+
+
+

Known issues / Roadmap

+
    +
  • Verify last day of month
  • +
  • Not yet compatible with cutoff module: create module for adaptation?
  • +
+
+
+

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 smashing it by providing a detailed and welcomed +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Onestein
  • +
+
+
+

Contributors

+ +
+
+

Other credits

+

Part of the code in this module (in particular the computation of the spread lines) +is highly inspired by the Assets Management module from the standard +Odoo 11.0 Community developed by Odoo SA.

+
+
+

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.

+

Current maintainer:

+

astirpe

+

This module is part of the OCA/account-financial-tools project on GitHub.

+

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

+
+
+
+ + diff --git a/account_spread_cost_revenue/static/description/invoice_line_1.png b/account_spread_cost_revenue/static/description/invoice_line_1.png new file mode 100644 index 00000000..7d899114 Binary files /dev/null and b/account_spread_cost_revenue/static/description/invoice_line_1.png differ diff --git a/account_spread_cost_revenue/static/description/invoice_line_2.png b/account_spread_cost_revenue/static/description/invoice_line_2.png new file mode 100644 index 00000000..b5278cac Binary files /dev/null and b/account_spread_cost_revenue/static/description/invoice_line_2.png differ diff --git a/account_spread_cost_revenue/static/description/spread.png b/account_spread_cost_revenue/static/description/spread.png new file mode 100644 index 00000000..cd2140b8 Binary files /dev/null and b/account_spread_cost_revenue/static/description/spread.png differ diff --git a/account_spread_cost_revenue/static/description/update_spread.png b/account_spread_cost_revenue/static/description/update_spread.png new file mode 100644 index 00000000..44b6dd32 Binary files /dev/null and b/account_spread_cost_revenue/static/description/update_spread.png differ diff --git a/account_spread_cost_revenue/static/src/js/account_spread.js b/account_spread_cost_revenue/static/src/js/account_spread.js new file mode 100644 index 00000000..68b941fc --- /dev/null +++ b/account_spread_cost_revenue/static/src/js/account_spread.js @@ -0,0 +1,62 @@ +odoo.define('account_spread_cost_revenue.widget', function (require) { + "use strict"; + + var AbstractField = require('web.AbstractField'); + var core = require('web.core'); + var registry = require('web.field_registry'); + + var _t = core._t; + + var AccountSpreadWidget = AbstractField.extend({ + events: _.extend({}, AbstractField.prototype.events, { + 'click': '_onClick', + }), + description: "", + + /** + * @override + */ + isSet: function () { + return this.value !== 'unavailable'; + }, + + /** + * @override + * @private + */ + _render: function () { + var className = ''; + var style = 'btn fa fa-arrow-circle-right o_spread_line '; + var title = ''; + if (this.recordData.spread_check === 'linked') { + className = 'o_is_linked'; + title = _t('Linked to spread'); + } else { + title = _t('Not linked to spread'); + } + var $button = $(' + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +