diff --git a/budget_control/README.rst b/budget_control/README.rst new file mode 100644 index 00000000..8cfae411 --- /dev/null +++ b/budget_control/README.rst @@ -0,0 +1,258 @@ +============== +Budget Control +============== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:353c8401879120bf194a5135e6f67838a807a00dc09ec0d8388ce36d90910040 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |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-ecosoft--odoo%2Fbudgeting-lightgray.png?logo=github + :target: https://github.com/ecosoft-odoo/budgeting/tree/16.0/budget_control + :alt: ecosoft-odoo/budgeting + +|badge1| |badge2| |badge3| + +This module is the main module from a set of budget control modules. +This module alone will allow you to work in full cycle of budget control process. +Other modules, each one are the small enhancement of this module, to fullfill +additional needs. Having said that, following will describe the full cycle of budget +control already provided by this module, + +Budget Control Core Features: +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* **Budget Commitment (base.budget.move)** + + Probably the most crucial part of budget_control. + + * Budget Balance = Budget Allocated - (Budget Actuals - Budget Commitments) + + Actual amount are from `account.move.line` from posted invoice. Commitments can be sales/purchase, + expense, purchase request, etc. Document required to be budget commitment can extend base.budget.move. + For example, the module budget_control_expense will create budget commitment `expense.budget.move` + for approved expense. + Note that, in this budget_control module, there is no extension for budget commitment yet. + +* **Budget Template (budget.template)** + + A Budget Template in the budget control system serves as a framework for controlling the budget, + allowing for the budget to be managed according to the pre-defined template. + The budget template has a relationship with the accounting, + and is used to control spending based on pre-configured accounts. + +* **Budget Period (budget.period)** + + Budget Period is the first thing to do for new budget year, and is used to govern how budget will be + controlled over the defined date range, i.e., + + * Duration of budget year + * Template to control (budget.template) + * Document to do budget checking + * Analytic account in controlled + * Control Level + + Although not mandatory, an organization will most likely use fiscal year as budget period. + In such case, there will be 1 budget period per fiscal year, and multiple budget control sheet (one per analytic). + +* **Budget Control Sheet (budget.control)** + + Each analytic account can have one budget control sheet per budget period. + The budget control is used to allocate budget amount in a simpler way. + In the backend it simply create budget.control.line, nothing too fancy. + Once we have budget allocations, the system is ready to perform budget check. + +* **Budget Checking** + + By calling function -- check_budget(), system will check whether the confirmation + of such document can result in negative budget balance. If so, it throw error message. + In this module, budget check occur during posting of invoice and journal entry. + To check budget also on more documents, do install budget_control_xxx relevant to that document. + +* **Budget Constraint** + + To make the function -- check_budget() more flexible, + additional rules or limitations can be added to the budget checking process. + The system will perform the regular budget check and will also check the additional conditions specified + in the added rules. An example of using budget constraints can be seen from the budget_allocation module. + +* **Budget Reports** + + Currently there are 2 types of report. + + 1. Budget Monitoring: combine all budget related transactions, and show them in Standard Odoo BI view. + 2. Actual Budget Moves: combine all actual commit transactions, and show them in Standard Odoo BI view. + +* **Budget Commitment Move Forward** + + In case budget commitment is being used. Sometime user has committed budget withing this year + but not ready to use it and want to move the commitment amount to next year budget. + Budget Commitment Forward can be use to change the budget move's date to the designated year. + +* **Budget Transfer** + + This module allow transferring allocated budget from one budget control sheet to other + + +Extended Modules: +~~~~~~~~~~~~~~~~~ + +Following are brief explanation of what the extended module will do. + +**Budget Move extension** + +These modules extend base.budget.move for other document budget commitment. + +* budget_control_expense +* budget_control_purchase +* budget_control_purchase_request +* budget_control_sale + +**Budget Allocation** + +This module is the main module for manage allocation (source of fund, analytic tag and analytic account) +until set budget control. and allow create Master Data source of fund, analytic tag dimension. +Users can view source of fund monitoring report + +* budget_allocation + +**Tier Validation** + +Extend base_tier_validation for budget control sheet + +* budget_control_tier_validation + +**Analytic Tag Dimension Enhancements** + +When 1 dimension (analytic account) is not enough, +we can use dimension to create persistent dimension columns + +- analytic_tag_dimension +- analytic_tag_dimension_enhanced + +Following modules ensure that, analytic_tag_dimension will work with all new +budget control objects. These are important for reporting purposes. + +* budget_allocation +* budget_allocation_expense +* budget_allocation_purchase + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Before start using this module, following access right must be set. + - Budget User for Budget Control Sheet, Budget Report + - Budget Manager for Budget Period + +Followings are sample steps to start with, + +1. Create new Budget KPI + + To create budget KPI using in budget template + +2. Create new Budget Template + + - Add new template for controlling Budget following kpi-account + +3. Create new Budget Period + + - Choose Budget template + - Identify date range, i.e., 1 fiscal year + - Plan Date Range, i.e., Quarter, the slot to fill allocation in budget control will split by quarter + - Control Budget = True (if not check = not check budget for this period) + +4. Create Budget Control Sheet + + To create budget control sheet, you can either create manually one by one or by using the helper, + Action > Create Budget Control Sheet + + - Choose Analytic budget_control_purchase_tag_dimension + - Check All Analytic Account, this will list all analytic account in selected groups + - Uncheck Initial Budget By Commitment, this is used only on following year to + init budget allocation if they were committed amount carried over. + - Click "Create Budget Control Sheet", and then view the newly created control sheets. + +5. Allocate amount in Budget Control Sheets + + Each analytic account will have its own sheet. Form Budget Period, click on the + icon "Budget Control Sheets" or by Menu > Budgeting > Budget Control Sheet, to open them. + + - Based on "Plan Date Range" period, Plan table will show all KPI split by Plan Date Range + - Allocate budget amount as appropriate. + - Click Control button, state will change to Controlled. + + Note: Make sure the Plan Date Rang period already has date ranges that covers entire budget period. + Once ready, you can click on "Reset Plan" anytime. + +6. Budget Reports + + After some document transaction (i.e., invoice for actuals), you can view report anytime. + + - On Budget Control sheet, click on Monitoring for see this budget report + - Menu Budgeting > Budget Monitoring, to show budget report in standard Odoo BI view. + +7. Budget Checking + + As we have checked Control Budget = True in third step, checking will occur + every time an invoice is validated. You can test by validate invoice with big amount to exceed. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Ecosoft + +Contributors +~~~~~~~~~~~~ + +* Kitti Upariphutthiphong +* Saran Lim. + +Maintainers +~~~~~~~~~~~ + +.. |maintainer-kittiu| image:: https://github.com/kittiu.png?size=40px + :target: https://github.com/kittiu + :alt: kittiu +.. |maintainer-ru3ix-bbb| image:: https://github.com/ru3ix-bbb.png?size=40px + :target: https://github.com/ru3ix-bbb + :alt: ru3ix-bbb + +Current maintainers: + +|maintainer-kittiu| |maintainer-ru3ix-bbb| + +This module is part of the `ecosoft-odoo/budgeting `_ project on GitHub. + +You are welcome to contribute. diff --git a/budget_control/__init__.py b/budget_control/__init__.py new file mode 100644 index 00000000..69650598 --- /dev/null +++ b/budget_control/__init__.py @@ -0,0 +1,5 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from . import models +from . import report +from . import wizards +from .hooks import update_data_hooks, uninstall_hook diff --git a/budget_control/__manifest__.py b/budget_control/__manifest__.py new file mode 100644 index 00000000..3c11361a --- /dev/null +++ b/budget_control/__manifest__.py @@ -0,0 +1,58 @@ +# Copyright 2020 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Budget Control", + "version": "16.0.1.0.0", + "category": "Accounting", + "license": "AGPL-3", + "author": "Ecosoft, Odoo Community Association (OCA)", + "website": "https://github.com/ecosoft-odoo/budgeting", + "depends": [ + "account", + "l10n_generic_coa", + "date_range", + "web_widget_x2many_2d_matrix", + ], + "data": [ + "data/sequence_data.xml", + "security/budget_control_security_groups.xml", + "security/budget_control_rules.xml", + "security/ir.model.access.csv", + "wizards/generate_budget_control_view.xml", + "wizards/analytic_budget_info_view.xml", + "wizards/analytic_budget_edit_view.xml", + "wizards/confirm_state_budget_view.xml", + "wizards/budget_commit_forward_info_view.xml", + "wizards/budget_balance_forward_info_view.xml", + "views/account_budget_move.xml", + "views/budget_menuitem.xml", + "views/budget_kpi_view.xml", + "views/budget_template_view.xml", + "views/res_config_settings_views.xml", + "views/budget_period_view.xml", + "views/budget_constraint_view.xml", + "views/budget_control_view.xml", + "views/analytic_account_views.xml", + "views/account_move_views.xml", + "views/account_journal_view.xml", + "views/budget_balance_forward_view.xml", + "views/budget_commit_forward_view.xml", + "views/budget_transfer_view.xml", + "views/budget_transfer_item_view.xml", + "views/budget_move_adjustment_view.xml", + "report/budget_monitor_report_view.xml", + "report/budget_move_views.xml", + ], + "demo": ["demo/budget_template_demo.xml"], + "assets": { + "web.assets_backend": [ + "budget_control/static/src/xml/budget_popover.xml", + ], + }, + "installable": True, + "maintainers": ["kittiu", "ru3ix-bbb"], + "post_init_hook": "update_data_hooks", + "uninstall_hook": "uninstall_hook", + "development_status": "Alpha", +} diff --git a/budget_control/data/sequence_data.xml b/budget_control/data/sequence_data.xml new file mode 100644 index 00000000..4a784be1 --- /dev/null +++ b/budget_control/data/sequence_data.xml @@ -0,0 +1,17 @@ + + + + Budget Transfer + budget.transfer + BT/%(year)s/ + 5 + + + + Budget Move Adjustment + budget.move.adjustment + BA/%(year)s/ + 5 + + + diff --git a/budget_control/demo/budget_template_demo.xml b/budget_control/demo/budget_template_demo.xml new file mode 100644 index 00000000..1240f338 --- /dev/null +++ b/budget_control/demo/budget_template_demo.xml @@ -0,0 +1,37 @@ + + + + + Expense + + + Purchase of Equipments + + + Rent + + + + Budget Template (demo) + + + + + + + + + + + + + + + + + + + diff --git a/budget_control/hooks.py b/budget_control/hooks.py new file mode 100644 index 00000000..d4cf6d63 --- /dev/null +++ b/budget_control/hooks.py @@ -0,0 +1,20 @@ +# Copyright 2020 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import SUPERUSER_ID, api + + +def update_data_hooks(cr, registry): + env = api.Environment(cr, SUPERUSER_ID, {}) + # Enable Analytic Account + env.ref("base.group_user").write( + {"implied_ids": [(4, env.ref("analytic.group_analytic_accounting").id)]} + ) + + +def uninstall_hook(cr, registry): + """Delete all data related to budget control""" + env = api.Environment(cr, SUPERUSER_ID, {}) + env["budget.template"].search([]).unlink() + env["budget.period"].search([]).unlink() + env["budget.control"].search([]).unlink() diff --git a/budget_control/models/__init__.py b/budget_control/models/__init__.py new file mode 100644 index 00000000..a2c4f531 --- /dev/null +++ b/budget_control/models/__init__.py @@ -0,0 +1,20 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import base_budget_move +from . import account_budget_move +from . import account_move +from . import account_move_line +from . import budget_kpi +from . import budget_template +from . import budget_period +from . import budget_control +from . import analytic_account +from . import budget_balance_forward +from . import budget_commit_forward +from . import budget_constraint +from . import res_company +from . import res_config_settings +from . import account_journal +from . import budget_transfer +from . import budget_transfer_item +from . import budget_move_adjustment diff --git a/budget_control/models/account_budget_move.py b/budget_control/models/account_budget_move.py new file mode 100644 index 00000000..ea433b51 --- /dev/null +++ b/budget_control/models/account_budget_move.py @@ -0,0 +1,50 @@ +# Copyright 2020 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class AccountBudgetMove(models.Model): + _name = "account.budget.move" + _inherit = ["base.budget.move"] + _description = "Account Budget Moves" + + # For journal entry + move_id = fields.Many2one( + comodel_name="account.move", + related="move_line_id.move_id", + readonly=True, + store=True, + index=True, + help="Commit budget for this move_id", + ) + move_line_id = fields.Many2one( + comodel_name="account.move.line", + readonly=True, + index=True, + help="Commit budget for this move_line_id", + ) + # For budget move adjustment + adjust_id = fields.Many2one( + comodel_name="budget.move.adjustment", + related="adjust_item_id.adjust_id", + readonly=True, + store=True, + index=True, + help="Commit budget for this adjust_id", + ) + adjust_item_id = fields.Many2one( + comodel_name="budget.move.adjustment.item", + readonly=True, + index=True, + help="Commit budget for this adjust_item_id", + ) + + @api.depends("move_id") + def _compute_reference(self): + for rec in self: + rec.reference = ( + rec.reference + if rec.reference + else (rec.move_id.display_name or rec.adjust_id.display_name) + ) diff --git a/budget_control/models/account_journal.py b/budget_control/models/account_journal.py new file mode 100644 index 00000000..6fbdbc43 --- /dev/null +++ b/budget_control/models/account_journal.py @@ -0,0 +1,12 @@ +# Copyright 2020 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class AccountJournal(models.Model): + _inherit = "account.journal" + + not_affect_budget = fields.Boolean( + help="Default value for journal entry for this journal", + ) diff --git a/budget_control/models/account_move.py b/budget_control/models/account_move.py new file mode 100644 index 00000000..11a19d6d --- /dev/null +++ b/budget_control/models/account_move.py @@ -0,0 +1,94 @@ +# Copyright 2020 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class AccountMove(models.Model): + _inherit = "account.move" + _docline_rel = "line_ids" + _docline_type = "account" + + not_affect_budget = fields.Boolean( + readonly=True, + states={"draft": [("readonly", False)]}, + help="If checked, lines does not create budget move", + ) + budget_move_ids = fields.One2many( + comodel_name="account.budget.move", + inverse_name="move_id", + string="Account Budget Moves", + ) + return_amount_commit = fields.Boolean( + help="This technical field is used to determine how to return budget " + "to the original document (i.e., return back to PO).\n" + "By default, system will use quantity to calculated for the returning amount. " + "But with this flag, the amount commit of this document will be used instead.\n" + "This is good when we want to ignore the quantity.\n" + "This flag usually passed in when this invoice is created.", + ) + + @api.model + def default_get(self, field_list): + res = super().default_get(field_list) + if res.get("journal_id"): + journal = self.env["account.journal"].browse(res["journal_id"]) + res["not_affect_budget"] = journal.not_affect_budget + return res + + @api.onchange("journal_id") + def _onchange_not_affect_budget(self): + self.not_affect_budget = self.journal_id.not_affect_budget + + def recompute_budget_move(self): + self.mapped("invoice_line_ids").recompute_budget_move() + + def close_budget_move(self): + self.mapped("invoice_line_ids").close_budget_move() + + @api.model_create_multi + def create(self, vals_list): + """The default value of "Not affect budget" depends on journal. + except in the case of a manaully created journal entry. + """ + for vals in vals_list: + if "not_affect_budget" not in vals and "journal_id" in vals: + journal = self.env["account.journal"].browse(vals["journal_id"]) + vals["not_affect_budget"] = journal.not_affect_budget + return super().create(vals_list) + + def write(self, vals): + """ + - Commit budget when state changes to actual + - Cancel/Draft document should delete all budget commitment + """ + res = super().write(vals) + if vals.get("state") in ("posted", "cancel", "draft"): + doclines = self.mapped("invoice_line_ids") + if vals.get("state") in ("cancel", "draft"): + # skip_account_move_synchronization = True, as this is account.move.line + # skipping to avoid warning error when update date_commit + doclines.with_context(skip_account_move_synchronization=True).write( + {"date_commit": False} + ) + doclines.recompute_budget_move() + return res + + def _filtered_move_check_budget(self): + """For hooks, default check budget following + - Vedor Bills + - Customer Refund + - Journal Entries + """ + return self.filtered_domain( + [("move_type", "in", ["in_invoice", "out_refund", "entry"])] + ) + + def action_post(self): + res = super().action_post() + # Update database, then check budget + self.flush_model() + BudgetPeriod = self.env["budget.period"] + for move in self._filtered_move_check_budget(): + BudgetPeriod.check_budget(move.line_ids) + return res diff --git a/budget_control/models/account_move_line.py b/budget_control/models/account_move_line.py new file mode 100644 index 00000000..b69e252d --- /dev/null +++ b/budget_control/models/account_move_line.py @@ -0,0 +1,69 @@ +# Copyright 2020 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class AccountMoveLine(models.Model): + _name = "account.move.line" + _inherit = ["account.move.line", "budget.docline.mixin"] + _budget_date_commit_fields = ["move_id.date"] + _budget_move_model = "account.budget.move" + _doc_rel = "move_id" + + can_commit = fields.Boolean( + compute="_compute_can_commit", + ) + budget_move_ids = fields.One2many( + comodel_name="account.budget.move", + inverse_name="move_line_id", + string="Account Budget Moves", + ) + return_amount_commit = fields.Boolean( + related="move_id.return_amount_commit", + ) + + @api.depends() + def _compute_can_commit(self): + res = super()._compute_can_commit() + no_budget_moves = self.mapped("move_id").filtered("not_affect_budget") + no_budget_moves.mapped("line_ids").update({"can_commit": False}) + return res + + def recompute_budget_move(self): + for invoice_line in self: + invoice_line.budget_move_ids.unlink() + # Commit on invoice + invoice_line.commit_budget() + + def _init_docline_budget_vals(self, budget_vals, analytic_id): + self.ensure_one() + if self.move_id.move_type == "entry": + total_amount = self.amount_currency + else: + sign = -1 if self.move_id.move_type in ("out_refund", "in_refund") else 1 + discount = (100 - self.discount) / 100 if self.discount else 1 + total_amount = sign * self.price_unit * self.quantity * discount + percent_analytic = self[self._budget_analytic_field].get(str(analytic_id)) + budget_vals["amount_currency"] = total_amount * percent_analytic / 100 + budget_vals["tax_ids"] = self.tax_ids.ids + # Document specific vals + budget_vals.update( + { + "move_line_id": self.id, + } + ) + return super()._init_docline_budget_vals(budget_vals, analytic_id) + + def _valid_commit_state(self): + return self.move_id.state == "posted" + + def _get_included_tax(self): + """Prepare for hook with extended modules""" + if self._name == "account.move.line": + return self.env.company.budget_include_tax_account + return self.env["account.tax"] + + def uncommit_purchase_budget(self): + """This function for hooks""" + return diff --git a/budget_control/models/analytic_account.py b/budget_control/models/analytic_account.py new file mode 100644 index 00000000..d23e14c1 --- /dev/null +++ b/budget_control/models/analytic_account.py @@ -0,0 +1,259 @@ +# Copyright 2021 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from dateutil.relativedelta import relativedelta + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class AccountAnalyticAccount(models.Model): + _inherit = "account.analytic.account" + + name_with_budget_period = fields.Char( + compute="_compute_name_with_budget_period", + store=True, + help="This field hold analytic name with budget period indicator.\n" + "This name will work with name_get() and name_search() to ensure usability", + ) + budget_period_id = fields.Many2one( + comodel_name="budget.period", + index=True, + ) + budget_control_ids = fields.One2many( + string="Budget Control(s)", + comodel_name="budget.control", + inverse_name="analytic_account_id", + readonly=True, + ) + bm_date_from = fields.Date( + string="Date From", + compute="_compute_bm_date", + store=True, + readonly=False, + tracking=True, + help="Budget commit date must conform with this date", + ) + bm_date_to = fields.Date( + string="Date To", + compute="_compute_bm_date", + store=True, + readonly=False, + tracking=True, + help="Budget commit date must conform with this date", + ) + auto_adjust_date_commit = fields.Boolean( + string="Auto Adjust Commit Date", + default=True, + help="Date From and Date To is used to determine valid date range of " + "this analytic account when using with budgeting system. If this data range " + "is setup, but the budget system set date_commit out of this date range " + "it it can be adjusted automatically.", + ) + amount_budget = fields.Monetary( + string="Budgeted", + compute="_compute_amount_budget_info", + help="Sum of amount plan", + ) + amount_consumed = fields.Monetary( + string="Consumed", + compute="_compute_amount_budget_info", + help="Consumed = Total Commitments + Actual", + ) + amount_balance = fields.Monetary( + string="Available", + compute="_compute_amount_budget_info", + help="Available = Total Budget - Consumed", + ) + initial_available = fields.Monetary( + copy=False, + readonly=True, + tracking=True, + help="Initial Balance come from carry forward available accumulated", + ) + initial_commit = fields.Monetary( + string="Initial Commitment", + copy=False, + readonly=True, + tracking=True, + help="Initial Balance from carry forward commitment", + ) + + @api.depends("name", "budget_period_id") + def _compute_name_with_budget_period(self): + for rec in self: + if rec.budget_period_id: + rec.name_with_budget_period = "{}: {}".format( + rec.budget_period_id.name, rec.name + ) + else: + rec.name_with_budget_period = rec.name + + def name_get(self): + res = [] + for analytic in self: + name = analytic.name_with_budget_period + if analytic.code: + name = ("[%(code)s] %(name)s") % {"code": analytic.code, "name": name} + if analytic.partner_id: + name = _("%(name)s - %(partner)s") % { + "name": name, + "partner": analytic.partner_id.commercial_partner_id.name, + } + res.append((analytic.id, name)) + return res + + @api.model + def name_search(self, name="", args=None, operator="ilike", limit=100): + # Make a search with default criteria + args = args or [] + names1 = super(models.Model, self).name_search( + name=name, args=args, operator=operator, limit=limit + ) + # Make search with name_with_budget_period + names2 = [] + if name: + domain = args + [("name_with_budget_period", "=ilike", name + "%")] + names2 = self.search(domain, limit=limit).name_get() + # Merge both results + return list(set(names1) | set(names2))[:limit] + + def _filter_by_analytic_account(self, val): + if val["analytic_account_id"][0] == self.id: + return True + return False + + def _compute_amount_budget_info(self): + """Note: This method is similar to BCS._compute_budget_info""" + BudgetPeriod = self.env["budget.period"] + MonitorReport = self.env["budget.monitor.report"] + query = BudgetPeriod._budget_info_query() + analytic_ids = self.ids + # Retrieve budgeting data for a list of budget_control + domain = [("analytic_account_id", "in", analytic_ids)] + # Optional filters by context + ctx = self.env.context.copy() + if ctx.get("no_fwd_commit"): + domain.append(("fwd_commit", "=", False)) + if ctx.get("budget_period_ids"): + domain.append(("budget_period_id", "in", ctx["budget_period_ids"])) + # -- + admin_uid = self.env.ref("base.user_admin").id + dataset_all = MonitorReport.with_user(admin_uid).read_group( + domain=domain, + fields=["analytic_account_id", "amount_type", "amount"], + groupby=["analytic_account_id", "amount_type"], + lazy=False, + ) + for rec in self: + # Filter according to budget_control parameter + dataset = list( + filter(lambda l: rec._filter_by_analytic_account(l), dataset_all) + ) + # Get data from dataset + budget_info = BudgetPeriod.get_budget_info_from_dataset(query, dataset) + rec.amount_budget = budget_info["amount_budget"] + rec.amount_consumed = budget_info["amount_consumed"] + rec.amount_balance = rec.amount_budget - rec.amount_consumed + + def _find_next_analytic(self, next_date_range): + self.ensure_one() + Analytic = self.env["account.analytic.account"] + next_analytic = Analytic.search( + [("name", "=", self.name), ("bm_date_from", "=", next_date_range)] + ) + return next_analytic + + def _update_val_analytic(self, next_analytic, next_date_range): + BudgetPeriod = self.env["budget.period"] + vals_update = {} + type_id = next_analytic.budget_period_id.plan_date_range_type_id + period_id = BudgetPeriod.search( + [ + ("bm_date_from", "=", next_date_range), + ("plan_date_range_type_id", "=", type_id.id), + ] + ) + if period_id: + vals_update = {"budget_period_id": period_id.id} + else: + # No budget period found, update date_from and date_to + vals_update = { + "bm_date_from": next_date_range, + "bm_date_to": next_analytic.bm_date_to + relativedelta(years=1), + } + return vals_update + + def _auto_create_next_analytic(self, next_date_range): + self.ensure_one() + # Core odoo will add (copy) after name, but we need same name + next_analytic = self.copy(default={"name": self.name}) + val_update = self._update_val_analytic(next_analytic, next_date_range) + next_analytic.write(val_update) + return next_analytic + + def next_year_analytic(self, auto_create=True): + """Find next analytic from analytic date_to + 1, + if bm_date_to = False, this is an open end analytic, always return False""" + self.ensure_one() + if not self.bm_date_to: + return False + next_date_range = self.bm_date_to + relativedelta(days=1) + next_analytic = self._find_next_analytic(next_date_range) + if not next_analytic and auto_create: + next_analytic = self._auto_create_next_analytic(next_date_range) + return next_analytic + + def _check_budget_control_status(self, budget_period_id=False): + """Warning for budget_control on budget_period, but not in controlled""" + domain = [("analytic_account_id", "in", self.ids)] + if budget_period_id: + domain.append(("budget_period_id", "=", budget_period_id)) + budget_controls = self.env["budget.control"].search(domain) + # Find analytics has no budget control sheet + bc_analytics = budget_controls.mapped("analytic_account_id") + no_bc_analytics = set(self) - set(bc_analytics) + if no_bc_analytics: + names = ", ".join([analytic.display_name for analytic in no_bc_analytics]) + raise UserError( + _("Following analytics has no budget control sheet:\n%s") % names + ) + # Find analytics has no controlled budget control sheet + budget_controlled = budget_controls.filtered_domain([("state", "=", "done")]) + cbc_analytics = budget_controlled.mapped("analytic_account_id") + no_cbc_analytics = set(self) - set(cbc_analytics) + if no_cbc_analytics: + names = ", ".join([analytic.display_name for analytic in no_cbc_analytics]) + raise UserError( + _( + "Budget control sheet for following analytics are not in " + "control:\n%s" + ) + % names + ) + + @api.depends("budget_period_id") + def _compute_bm_date(self): + """Default effective date, but changable""" + for rec in self: + rec.bm_date_from = rec.budget_period_id.bm_date_from + rec.bm_date_to = rec.budget_period_id.bm_date_to + + def _auto_adjust_date_commit(self, docline): + for rec in self: + if not rec.auto_adjust_date_commit: + continue + if rec.bm_date_from and rec.bm_date_from > docline.date_commit: + docline.date_commit = rec.bm_date_from + elif rec.bm_date_to and rec.bm_date_to < docline.date_commit: + docline.date_commit = rec.bm_date_to + + def action_edit_initial_available(self): + return { + "name": _("Edit Analytic Budget"), + "type": "ir.actions.act_window", + "res_model": "analytic.budget.edit", + "view_mode": "form", + "target": "new", + "context": {"default_initial_available": self.initial_available}, + } diff --git a/budget_control/models/base_budget_move.py b/budget_control/models/base_budget_move.py new file mode 100644 index 00000000..665a2c8c --- /dev/null +++ b/budget_control/models/base_budget_move.py @@ -0,0 +1,640 @@ +# Copyright 2020 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from datetime import datetime +from json import dumps + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError + + +class BaseBudgetMove(models.AbstractModel): + _name = "base.budget.move" + _description = "Document Budget Moves" + _budget_control_field = "account_id" + _order = "analytic_account_id, date, id" + + reference = fields.Char( + compute="_compute_reference", + store=True, + readonly=False, + index=True, + help="Reference to document number of extending model", + ) + source_document = fields.Char( + compute="_compute_source_document", + store=True, + readonly=False, + index=True, + help="Reference to Source document number of extending model", + ) + template_line_id = fields.Many2one( + comodel_name="budget.template.line", + index=True, + ) + kpi_id = fields.Many2one( + comodel_name="budget.kpi", + compute="_compute_kpi_id", + store=True, + ) + date = fields.Date( + required=True, + index=True, + ) + product_id = fields.Many2one( + comodel_name="product.product", + ) + account_id = fields.Many2one( + comodel_name="account.account", + string="Account", + auto_join=True, + index=True, + readonly=True, + ) + analytic_account_id = fields.Many2one( + comodel_name="account.analytic.account", + string="Analytic Account", + auto_join=True, + index=True, + readonly=True, + ) + analytic_plan = fields.Many2one( + comodel_name="account.analytic.plan", + auto_join=True, + index=True, + readonly=True, + ) + amount_currency = fields.Float( + required=True, + help="Amount in multi currency", + ) + credit = fields.Float( + readonly=True, + ) + debit = fields.Float( + readonly=True, + ) + company_id = fields.Many2one( + comodel_name="res.company", + string="Company", + required=True, + default=lambda self: self.env.user.company_id.id, + index=True, + ) + note = fields.Char( + readonly=True, + ) + adj_commit = fields.Boolean( + help="This budget move line is the result of Over returned 'Automatic Adjustment'", + ) + fwd_commit = fields.Boolean( + help="This budget move line is the result of 'Forward Budget Commitment'", + ) + + @api.depends("template_line_id") + def _compute_kpi_id(self): + for rec in self: + rec.kpi_id = rec.template_line_id.kpi_id + + def _compute_reference(self): + """Compute reference name of the budget move document""" + self.update({"reference": False}) + + def _compute_source_document(self): + """Compute source document of the budget move document""" + self.update({"source_document": False}) + + +class BudgetDoclineMixinBase(models.AbstractModel): + _name = "budget.docline.mixin.base" + _description = ( + "Base of budget.docline.mixin, used for non budgeting model extension" + ) + _budget_analytic_field = "analytic_distribution" + # Budget related variables + _budget_date_commit_fields = [] # Date used for budget commitment + _budget_move_model = False # account.budget.move + _budget_move_field = "budget_move_ids" + _doc_rel = False # Reference to header object of docline + _no_date_commit_states = [ + "draft", + "cancel", + "rejected", + ] # Never set date commit states + + +class BudgetDoclineMixin(models.AbstractModel): + _name = "budget.docline.mixin" + _inherit = ["budget.docline.mixin.base"] + _description = "Mixin used in each document line model that commit budget" + + can_commit = fields.Boolean( + compute="_compute_can_commit", + help="If True, this docline is eligible to create budget move", + ) + amount_commit = fields.Json( + compute="_compute_commit", + copy=False, + store=True, + ) + date_commit = fields.Date( + compute="_compute_commit", + store=True, + copy=False, + readonly=False, # Allow manual entry of this field + ) + auto_adjust_date_commit = fields.Boolean( + compute="_compute_auto_adjust_date_commit", + readonly=True, + ) + fwd_analytic_distribution = fields.Json( + string="Carry Forward Analytic", + copy=False, + help="If specified, recompute budget will take this into account", + ) + fwd_date_commit = fields.Date( + string="Carry Forward Date Commit", + copy=False, + readonly=False, + help="If specified, recompute budget will take this into account", + ) + json_budget_popover = fields.Char( + compute="_compute_json_budget_popover", + help="Show budget condition of selected Analytic", + ) + + def _budget_model(self): + return self.env.context.get("alt_budget_move_model") or self._budget_move_model + + def _budget_field(self): + return self.env.context.get("alt_budget_move_field") or self._budget_move_field + + def _valid_commit_state(self): + raise ValidationError(_("No implementation error!")) + + def _convert_analytics(self, analytic_distribution=False): + Analytic = self.env["account.analytic.account"] + analytics = analytic_distribution or self[self._budget_analytic_field] + if not analytics: + return Analytic + # Check analytic from distribution it send data with JSON type 'dict' + # and we need convert it to analytic object + if self._budget_analytic_field == "analytic_distribution": + account_analytic_ids = [int(k) for k in analytics.keys()] + analytics = Analytic.browse(account_analytic_ids) + return analytics + + @api.depends(lambda self: [self._budget_analytic_field]) + def _compute_auto_adjust_date_commit(self): + """Auto adjust is True if some analytic account is checked auto adjust""" + for docline in self: + analytics = docline._convert_analytics() + docline.auto_adjust_date_commit = any( + aa.auto_adjust_date_commit for aa in analytics + ) + + @api.depends() + def _compute_can_commit(self): + """Determine if this document is eligible for budget commitment.""" + # All required fields are set + required_fields = self._required_fields_to_commit() + domain = [(field, "!=", False) for field in required_fields] + records = self.filtered_domain(domain) + records.update({"can_commit": True}) + (self - records).update({"can_commit": False}) + + def _filter_current_move(self, analytic): + self.ensure_one() + return self.budget_move_ids.filtered( + lambda l: l.analytic_account_id == analytic + ) + + @api.depends("budget_move_ids", "budget_move_ids.date") + def _compute_commit(self): + """ + - Calc amount_commit from all budget_move_ids + - Calc date_commit if not exists and on 1st budget_move_ids only or False + """ + for rec in self: + analytic_distribution = rec[self._budget_analytic_field] + # Add analytic_distribution from forward_commit + if rec.fwd_analytic_distribution: + for analytic_id, aa_percent in rec.fwd_analytic_distribution.items(): + analytic_distribution[analytic_id] = aa_percent + + if not analytic_distribution: + continue + # Compute amount commit each analytic + amount_commit_json = {} + for analytic_id in analytic_distribution: # Get id only + budget_move = rec.budget_move_ids.filtered( + lambda move: move.analytic_account_id.id == int(analytic_id) + ) + debit = sum(budget_move.mapped("debit")) + credit = sum(budget_move.mapped("credit")) + amount_commit_json[analytic_id] = debit - credit + rec.amount_commit = amount_commit_json + # Compute date commit + if rec.budget_move_ids: + rec.date_commit = min(rec.budget_move_ids.mapped("date")) + else: + rec.date_commit = rec.date_commit + + def _compute_json_budget_popover(self): + FloatConverter = self.env["ir.qweb.field.float"] + for rec in self: + analytic_distribution = rec[self._budget_analytic_field] + analytic_account = rec._convert_analytics( + analytic_distribution=analytic_distribution + ) + if not analytic_account: + rec.json_budget_popover = False + continue + # Budget Period is required, even a False one + budget_period = self.env["budget.period"]._get_eligible_budget_period( + date=rec.date_commit + ) + rec.json_budget_popover = dumps( + { + "title": _("Budget Figure"), + "icon": "fa-info-circle", + "popoverTemplate": "budget_control.budgetPopOver", + "analytic": [ + { + "id": aa.id, + "name": aa.display_name, + "budget": FloatConverter.value_to_html( + aa.amount_budget, {"decimal_precision": "Product Price"} + ), + "consumed": FloatConverter.value_to_html( + aa.amount_consumed, + {"decimal_precision": "Product Price"}, + ), + "balance": FloatConverter.value_to_html( + aa.amount_balance, + {"decimal_precision": "Product Price"}, + ), + } + for aa in analytic_account.with_context( + budget_period_ids=[budget_period.id] + ) + ], + } + ) + + def _get_budget_date_commit(self, docline): + dates = [ + docline.mapped(f)[0] + for f in self._budget_date_commit_fields + if docline.mapped(f)[0] + ] + if dates: + if isinstance(dates[0], datetime): + date_commit = fields.Datetime.context_timestamp(self, dates[0]) + else: + date_commit = dates[0] + else: + date_commit = False + return date_commit + + def _set_date_commit(self): + """Default implementation, use date from _doc_date_field + which is mostly write_date during budget commitment""" + self.ensure_one() + # skip_account_move_synchronization = True, as this can be account.move.line + # skipping to avoid warning error when update date_commit + docline = self.with_context(skip_account_move_synchronization=True) + # Use the force_date_commit if it's set in the context. + if self.env.context.get("force_date_commit"): + docline.date_commit = self.env.context["force_date_commit"] + return + if not self._budget_date_commit_fields: + raise ValidationError(_("'_budget_date_commit_fields' is not set!")) + analytic = docline._convert_analytics() + # If the analytic field is not set, set the date commit to False and return. + if not analytic: + docline.date_commit = False + return + # If the date commit is already set, return. + if docline.date_commit: + return + # Get dates following _budget_date_commit_fields + docline.date_commit = self._get_budget_date_commit(docline) + # If the date_commit is not in the analytic date range, use a possible date. + analytic._auto_adjust_date_commit(docline) + + def _get_amount_convert_currency( + self, amount_currency, currency, company, date_commit + ): + return currency._convert( + amount_currency, company.currency_id, company, date_commit + ) + + def _update_budget_commitment(self, budget_vals, analytic, reverse=False): + self.ensure_one() + company = self.env.user.company_id + account = self.account_id + budget_moves = self[self._budget_field()] + date_commit = budget_vals.get( + "date", + max(budget_moves.mapped("date")) if budget_moves else self.date_commit, + ) + currency = hasattr(self, "currency_id") and self.currency_id or False + amount = budget_vals["amount_currency"] # init + today = fields.Date.context_today(self) + if ( + not self.env.context.get("use_amount_commit") + and currency + and currency != company.currency_id + ): + amount = self._get_amount_convert_currency( + budget_vals["amount_currency"], currency, company, date_commit or today + ) + # NOTE: This is to handle the case of budget revenue. + if ( + self._name == "account.move.line" + and self.move_id.move_type == "out_invoice" + ): + reverse = True + # By default, commit date is equal to document date + # this is correct for normal case, but may require different date + # in case of budget that carried to new period/year + res = { + "product_id": self.product_id.id, + "account_id": account.id, + "analytic_account_id": analytic.id, + "analytic_plan": analytic.plan_id.id, + "date": date_commit or today, + "amount_currency": budget_vals["amount_currency"], + "debit": not reverse and amount or 0, + "credit": reverse and amount or 0, + "company_id": company.id, + } + if sum([res["debit"], res["credit"]]) < 0: + res["debit"], res["credit"] = abs(res["credit"]), abs(res["debit"]) + budget_vals.update(res) + return budget_vals + + def _update_template_line(self, budget_move): + self.ensure_one() + BudgetPeriod = self.env["budget.period"] + budget_period = BudgetPeriod._get_eligible_budget_period(self.date_commit) + if not budget_period: + return budget_move + controls = BudgetPeriod.with_context(need_control=True)._prepare_controls( + budget_period, self + ) + template_lines = budget_period.template_id.line_ids + # Get KPI, when possible. + if controls and template_lines: + template_line = BudgetPeriod._get_kpi_by_control_key( + template_lines, controls[0] + ) + budget_move.template_line_id = template_line.id + # Set KPI for check budget + budget_move.kpi_id = template_line.kpi_id.id + return budget_move + + def _get_domain_fwd_line(self, docline): + return [ + ("res_model", "=", docline._name), + ("res_id", "=", docline.id), + ("forward_id.state", "=", "done"), + ] + + def forward_commit(self): + # allow all user can do it because this is common function + self = self.sudo() + ForwardLine = self.env["budget.commit.forward.line"] + BudgetPeriod = self.env["budget.period"] + for docline in self: + if not docline.fwd_analytic_distribution or not docline.fwd_date_commit: + return + if ( + docline[self._budget_analytic_field] + == docline.fwd_analytic_distribution + and docline.date_commit == docline.fwd_date_commit + ): # no forward to same date + return + domain_fwd_line = self._get_domain_fwd_line(docline) + fwd_lines = ForwardLine.search(domain_fwd_line) + # NOTE: this function will support commit forward more than 1 time + # carry forward - get line with it self or other year + if self.env.context.get("active_model") == "budget.commit.forward": + active_id = self.env.context.get("active_id", False) + fwd_lines.filtered( + lambda l: ( + l.forward_id.state == "review" and l.forward_id.id == active_id + ) + or l.forward_id.state == "done" + ) + else: # recompute budget + fwd_lines.filtered(lambda l: l.forward_id.state == "done") + for fwd_line in fwd_lines: + # find last date of carry forward + budget_period = BudgetPeriod._get_eligible_budget_period( + fwd_line.date_commit + ) + # create commitment carry (credit) + budget_move = docline.with_context( + use_amount_commit=True, + commit_note=_("Commitment carry forward"), + fwd_commit=True, + fwd_amount_commit=fwd_line.amount_commit, + ).commit_budget( + reverse=True, + date=budget_period.bm_date_to, + analytic_account_id=fwd_line.analytic_account_id, + ) + # create commitment carry (debit) + if budget_move: + fwd_budget_move = budget_move.copy() + debit = fwd_budget_move.debit + credit = fwd_budget_move.credit + fwd_budget_move.write( + { + "analytic_account_id": fwd_line.to_analytic_account_id.id, + "date": fwd_line.forward_id.to_date_commit, + "credit": debit, + "debit": credit, + } + ) + # Remove forward commitment from unused subsequent year budget lines + # If a budget line was forwarded to the next year but the budget + # for that year is not utilized, this code removes the forward commitment, + # allowing the line to be forwarded again in the following year. + budget_move_previous_forward = self[self._budget_field()].filtered( + lambda l: l.fwd_commit + and l.date < fwd_line.forward_id.to_date_commit + and l.debit > 0.0 + ) + if budget_move_previous_forward: + budget_move_previous_forward.write({"fwd_commit": False}) + + def commit_budget(self, reverse=False, **vals): + """Create budget commit for each docline""" + required_analytic = self.env.user.has_group( + "budget_control.group_required_analytic" + ) + # Required all document except move that check 'Not Affect Budget' + # and not 'Tax' and display_type is not false + if ( + required_analytic + and (hasattr(self, "display_type") and not self.display_type) + and not self[self._budget_analytic_field] + and not ( + self._name == "account.move.line" + and (self.move_id.not_affect_budget or self.tax_line_id) + ) + ): + raise UserError(_("Please fill analytic account.")) + self.prepare_commit() + to_commit = self.env.context.get("force_commit") or self._valid_commit_state() + if self.can_commit and to_commit: + budget_commit_vals = [] + # Specific analytic account + if vals.get("analytic_account_id", False): + analytic_account = vals["analytic_account_id"] + else: + analytic_account = self._convert_analytics( + analytic_distribution=vals.get("analytic_distribution", False) + ) + # Delete analytic_distribution from vals + if vals.get("analytic_distribution", "/") != "/": + del vals["analytic_distribution"] + + for analytic in analytic_account: + # Set amount_currency + budget_vals = self._init_docline_budget_vals(vals, analytic.id) + # Case budget_include_tax = True + budget_vals = self._budget_include_tax(budget_vals) + # Case force use_amount_commit, this should overwrite tax compute + if self.env.context.get("use_amount_commit"): + budget_vals["amount_currency"] = self.amount_commit[ + str(analytic.id) + ] + # Case forward_commit + if self.env.context.get("fwd_amount_commit"): + budget_vals["amount_currency"] = self.env.context.get( + "fwd_amount_commit" + ) + # Only on case reverse, to force use return_amount_commit + if reverse and "return_amount_commit" in self.env.context: + budget_vals["amount_currency"] = self.env.context.get( + "return_amount_commit" + ) + # Complete budget commitment dict + budget_vals = self._update_budget_commitment( + budget_vals, analytic, reverse=reverse + ) + # Final note + budget_vals["note"] = self.env.context.get("commit_note") + # Is Adjustment Commit + budget_vals["adj_commit"] = self.env.context.get("adj_commit") + # Is Forward Commit + budget_vals["fwd_commit"] = self.env.context.get("fwd_commit") + # Create budget move + if not budget_vals["amount_currency"]: + return False + budget_commit_vals.append(budget_vals.copy()) + # Clear old values for case multi analytics + del budget_vals["amount_currency"] + budget_move = self.env[self._budget_model()].create(budget_commit_vals) + # Update Template Line + budget_move = self._update_template_line(budget_move) + if reverse: # On reverse, make sure not over returned + self.env["budget.period"].check_over_returned_budget(self) + return budget_move + else: + self[self._budget_field()].unlink() + + def _required_fields_to_commit(self): + return [self._budget_analytic_field] + + def _init_docline_budget_vals(self, budget_vals, analytic_id): + """To be extended by docline to add untaxed amount_currency""" + if "amount_currency" not in budget_vals: + raise ValidationError(_("No amount_currency passed in!")) + return budget_vals + + def _taxes_included(self, taxes): + """Check configuration, both document and tax type""" + if not self.env.company.budget_include_tax: + return False + else: + if self.env.company.budget_include_tax_method == "all": + return taxes + if self.env.company.budget_include_tax_method == "specific": + included_taxes = self._get_included_tax() + return taxes & included_taxes + return False + + def _budget_include_tax(self, budget_vals): + if "tax_ids" not in budget_vals: + return budget_vals + tax_ids = budget_vals.pop("tax_ids") + if tax_ids: + is_refund = False + if self._name == "account.move.line" and self.move_id.move_type in ( + "in_refund", + "out_refund", + ): + is_refund = True + all_taxes = self.env["account.tax"].browse(tax_ids) + # For included taxes case + included_taxes = self._taxes_included(all_taxes) + if included_taxes: + res = included_taxes.compute_all( + budget_vals["amount_currency"], is_refund=is_refund + ) + budget_vals["amount_currency"] = res["total_included"] + else: + res = all_taxes.compute_all( + budget_vals["amount_currency"], is_refund=is_refund + ) + budget_vals["amount_currency"] = res["total_excluded"] + return budget_vals + + def prepare_commit(self): + self.ensure_one() + if self[ + self._doc_rel + ].state not in self._no_date_commit_states or self.env.context.get( + "force_commit" + ): # precommit case + self._set_date_commit() + if self.can_commit: # Check only the can_commit lines + self._check_date_commit() # Testing only, can be removed when stable + + def _check_date_commit(self): + """Commit date must inline with analytic account""" + self.ensure_one() + docline = self + analytics = docline._convert_analytics() + if analytics: + if not docline.date_commit: + raise UserError(_("No budget commitment date")) + for analytic in analytics: + date_from = analytic.bm_date_from + date_to = analytic.bm_date_to + if (date_from and date_from > docline.date_commit) or ( + date_to and date_to < docline.date_commit + ): + raise UserError( + _("Budget date commit is not within date range of - %s") + % analytic.display_name + ) + else: + if docline.date_commit: + raise UserError(_("Budget commitment date not required")) + + def close_budget_move(self): + """Reverse commit with amount_commit/date_commit to zero budget""" + for docline in self: + docline.with_context( + use_amount_commit=True, + commit_note=_("Auto adjustment on close budget"), + adj_commit=True, + ).commit_budget( + reverse=True, analytic_distribution=docline.fwd_analytic_distribution + ) diff --git a/budget_control/models/budget_balance_forward.py b/budget_control/models/budget_balance_forward.py new file mode 100644 index 00000000..d7b6221f --- /dev/null +++ b/budget_control/models/budget_balance_forward.py @@ -0,0 +1,379 @@ +# Copyright 2020 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from collections import Counter + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError + + +class BudgetBalanceForward(models.Model): + _name = "budget.balance.forward" + _description = "Budget Balance Forward" + _inherit = ["mail.thread"] + + name = fields.Char( + required=True, + readonly=True, + states={"draft": [("readonly", False)]}, + ) + from_budget_period_id = fields.Many2one( + comodel_name="budget.period", + string="From Budget Period", + required=True, + ondelete="restrict", + readonly=True, + states={"draft": [("readonly", False)]}, + default=lambda self: self.env["budget.period"]._get_eligible_budget_period(), + ) + to_budget_period_id = fields.Many2one( + comodel_name="budget.period", + string="To Budget Period", + required=True, + ondelete="restrict", + readonly=True, + states={"draft": [("readonly", False)]}, + ) + state = fields.Selection( + [ + ("draft", "Draft"), + ("review", "Review"), + ("done", "Done"), + ("cancel", "Cancelled"), + ], + string="Status", + readonly=True, + copy=False, + index=True, + default="draft", + tracking=True, + ) + forward_line_ids = fields.One2many( + comodel_name="budget.balance.forward.line", + inverse_name="forward_id", + string="Forward Lines", + readonly=True, + ) + currency_id = fields.Many2one( + comodel_name="res.currency", + default=lambda self: self.env.user.company_id.currency_id, + ) + missing_analytic = fields.Boolean( + compute="_compute_missing_analytic", + help="Not all forward lines has been assigned with carry forward analytic", + ) + _sql_constraints = [ + ("name_uniq", "UNIQUE(name)", "Name must be unique!"), + ] + + @api.constrains("from_budget_period_id", "to_budget_period_id") + def _check_budget_period(self): + for rec in self: + if ( + rec.to_budget_period_id.bm_date_from + <= rec.from_budget_period_id.bm_date_to + ): + raise ValidationError( + _("'To Budget Period' must be later than 'From Budget Period'") + ) + + def _compute_missing_analytic(self): + for rec in self: + rec.missing_analytic = any( + rec.forward_line_ids.filtered_domain( + [("to_analytic_account_id", "=", False)] + ) + ) + + def _get_other_forward(self): + query = """ + SELECT fw_line.analytic_account_id + FROM budget_balance_forward_line fw_line + LEFT JOIN budget_balance_forward fw + ON fw.id = fw_line.forward_id + WHERE fw.state in ('review', 'done') + AND fw.id != %s + AND fw.from_budget_period_id = %s + """ + params = (self.id, self.from_budget_period_id.id) + self.env.cr.execute(query, params) + return self.env.cr.dictfetchall() + + def _prepare_vals_forward(self): + """Retrieve Analytic Account relevant to from_budget_period""" + self.ensure_one() + # Ensure that budget info will be based on this period, and no_fwd_commit + self = self.with_context( + budget_period_ids=self.from_budget_period_id.ids, + no_fwd_commit=True, + ) + # Analyic Account from budget control sheet of the previous year + BudgetControl = self.env["budget.control"] + budget_controls = BudgetControl.search( + [("budget_period_id", "=", self.from_budget_period_id.id)] + ) + analytics = budget_controls.mapped("analytic_account_id") + # Find document forward balance is used. it should skip it. + query_analytic = self._get_other_forward() + analytic_dup_ids = [x["analytic_account_id"] for x in query_analytic] + value_dict = [] + for analytic in analytics: + if analytic.id in analytic_dup_ids: + continue + method_type = False + if ( + analytic.bm_date_to + and analytic.bm_date_to < self.to_budget_period_id.bm_date_from + ): + method_type = "new" + value_dict.append( + { + "forward_id": self.id, + "analytic_account_id": analytic.id, + "method_type": method_type, + "amount_balance": analytic.amount_balance, + "amount_balance_forward": 0 + if analytic.amount_balance < 0 + else analytic.amount_balance, + } + ) + return value_dict + + def action_review_budget_balance(self): + for rec in self: + rec.get_budget_balance_forward() + self.write({"state": "review"}) + + def get_budget_balance_forward(self): + """Get budget balance on each analytic account.""" + self = self.sudo() + Line = self.env["budget.balance.forward.line"] + for rec in self: + vals = rec._prepare_vals_forward() + Line.create(vals) + + def create_missing_analytic(self): + for rec in self: + for line in rec.forward_line_ids.filtered_domain( + [("to_analytic_account_id", "=", False)] + ): + line.to_analytic_account_id = ( + line.analytic_account_id.next_year_analytic() + ) + + def preview_budget_balance_forward_info(self): + self.ensure_one() + if self.missing_analytic: + raise UserError( + _( + "Some carry forward analytic accounts are missing.\n" + "Click 'Create Missing Analytics' button to create for next budget period." + ) + ) + wizard = self.env.ref("budget_control.view_budget_balance_forward_info_form") + forward_vals = self._get_forward_initial_balance() + return { + "name": _("Preview Budget Balance"), + "type": "ir.actions.act_window", + "view_mode": "form", + "res_model": "budget.balance.forward.info", + "views": [(wizard.id, "form")], + "view_id": wizard.id, + "target": "new", + "context": { + "default_forward_id": self.id, + "default_forward_info_line_ids": forward_vals, + }, + } + + def _get_forward_initial_balance(self): + """Get analytic accounts from both to_analtyic_account_id + and accumulate_analytic_account_id""" + self.ensure_one() + + def get_amount(k, v): + forwards = self.env["budget.balance.forward.line"].read_group( + [ + ("forward_id", "=", self.id), + ("forward_id.state", "in", ["review", "done"]), + (k, "!=", False), + ], + [k, v], + [k], + orderby=v, + ) + return {f[k][0]: f[v] for f in forwards} + + # From to_analytic_account_id + res_a = get_amount("to_analytic_account_id", "amount_balance_forward") + res_b = get_amount( + "accumulate_analytic_account_id", "amount_balance_accumulate" + ) + # Sum amount of the same analytic, and return as list + res = dict(Counter(res_a) + Counter(res_b)) + res = [ + { + "analytic_account_id": analytic_id, + "initial_available": amount, + } + for analytic_id, amount in res.items() + ] + return res + + def _do_update_initial_avaliable(self): + """Update all Analytic Account's initial commit value related to budget period""" + self.ensure_one() + # Reset all lines + Analytic = self.env["account.analytic.account"] + analytic_carry_forward = self.forward_line_ids.mapped("to_analytic_account_id") + analytic_accumulate = self.forward_line_ids.mapped( + "accumulate_analytic_account_id" + ) + analytics = analytic_carry_forward + analytic_accumulate + analytics.write({"initial_available": 0.0}) + # -- + forward_vals = self._get_forward_initial_balance() + for val in forward_vals: + analytic = Analytic.browse(val["analytic_account_id"]) + analytic.initial_available = val["initial_available"] + + def action_budget_balance_forward(self): + # For extend mode, make sure bm_date_to is extended + for rec in self: + for line in rec.forward_line_ids: + if line.method_type == "extend": + line.to_analytic_account_id.bm_date_to = ( + rec.to_budget_period_id.bm_date_to + ) + # -- + self.write({"state": "done"}) + self._do_update_initial_avaliable() + + def action_cancel(self): + self.write({"state": "cancel"}) + self._do_update_initial_avaliable() + + def action_draft(self): + self.mapped("forward_line_ids").unlink() + self.write({"state": "draft"}) + self._do_update_initial_avaliable() + + +class BudgetBalanceForwardLine(models.Model): + _name = "budget.balance.forward.line" + _description = "Budget Balance Forward Line" + + forward_id = fields.Many2one( + comodel_name="budget.balance.forward", + string="Forward Balance", + index=True, + required=True, + readonly=True, + ondelete="cascade", + ) + analytic_account_id = fields.Many2one( + comodel_name="account.analytic.account", + index=True, + required=True, + readonly=True, + ) + amount_balance = fields.Monetary( + string="Balance", + required=True, + readonly=True, + ) + method_type = fields.Selection( + selection=[ + ("new", "New"), + ("extend", "Extend"), + ], + string="Method", + help="New: if the analytic has ended, 'To Analytic Account' is required\n" + "Extended: if the analytic has ended, but want to extend to next period date end", + ) + to_analytic_account_id = fields.Many2one( + comodel_name="account.analytic.account", + string="Carry Forward Analytic", + compute="_compute_to_analytic_account_id", + store=True, + readonly=True, + ) + bm_date_to = fields.Date( + related="analytic_account_id.bm_date_to", + readonly=True, + ) + currency_id = fields.Many2one( + related="forward_id.currency_id", + readonly=True, + ) + amount_balance_forward = fields.Monetary( + string="Forward", + ) + accumulate_analytic_account_id = fields.Many2one( + comodel_name="account.analytic.account", + string="Accumulate Analytic", + ) + amount_balance_accumulate = fields.Monetary( + string="Accumulate", + compute="_compute_amount_balance_accumulate", + inverse="_inverse_amount_balance_accumulate", + store=True, + ) + + @api.constrains("amount_balance_forward", "amount_balance_accumulate") + def _check_amount(self): + for rec in self: + if rec.amount_balance_forward < 0 or rec.amount_balance_accumulate < 0: + raise ValidationError(_("Negative amount is not allowed")) + if rec.amount_balance_accumulate and not rec.accumulate_analytic_account_id: + raise ValidationError( + _("Accumulate Analytic is requried for lines when Accumulate > 0") + ) + + @api.depends("method_type") + def _compute_to_analytic_account_id(self): + for rec in self: + # Case analytic has no end date, always use same analytic + if not rec.analytic_account_id.bm_date_to: + rec.to_analytic_account_id = rec.analytic_account_id + rec.method_type = False + continue + # Case analytic has extended end date that cover new balance date, use same analytic + if ( + rec.analytic_account_id.bm_date_to + and rec.analytic_account_id.bm_date_to + >= rec.forward_id.to_budget_period_id.bm_date_from + ): + rec.to_analytic_account_id = rec.analytic_account_id + rec.method_type = "extend" + continue + # Case want to extend analytic to end of next budget period + if rec.method_type == "extend": + rec.to_analytic_account_id = rec.analytic_account_id + continue + # Case want to use next analytic, if exists + if rec.method_type == "new": + rec.to_analytic_account_id = rec.analytic_account_id.next_year_analytic( + auto_create=False + ) + + @api.depends("amount_balance_forward") + def _compute_amount_balance_accumulate(self): + for rec in self: + if rec.amount_balance <= 0: + rec.amount_balance_accumulate = 0 + rec.amount_balance_forward = 0 + continue + rec.amount_balance_accumulate = ( + rec.amount_balance - rec.amount_balance_forward + ) + + @api.onchange("amount_balance_accumulate") + def _inverse_amount_balance_accumulate(self): + for rec in self: + if rec.amount_balance <= 0: + rec.amount_balance_forward = 0 + continue + rec.amount_balance_forward = ( + rec.amount_balance - rec.amount_balance_accumulate + ) diff --git a/budget_control/models/budget_commit_forward.py b/budget_control/models/budget_commit_forward.py new file mode 100644 index 00000000..8fcbf515 --- /dev/null +++ b/budget_control/models/budget_commit_forward.py @@ -0,0 +1,441 @@ +# Copyright 2020 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class BudgetCommitForward(models.Model): + _name = "budget.commit.forward" + _description = "Budget Commit Forward" + _inherit = ["mail.thread"] + + name = fields.Char( + required=True, + readonly=True, + states={"draft": [("readonly", False)]}, + ) + to_budget_period_id = fields.Many2one( + comodel_name="budget.period", + string="To Budget Period", + required=True, + ondelete="restrict", + readonly=True, + states={"draft": [("readonly", False)]}, + ) + to_date_commit = fields.Date( + related="to_budget_period_id.bm_date_from", + string="Move commit to date", + ) + filter_lines = fields.Many2many( + comodel_name="budget.commit.forward.line", + ) + state = fields.Selection( + [ + ("draft", "Draft"), + ("review", "Review"), + ("done", "Done"), + ("cancel", "Cancelled"), + ], + string="Status", + readonly=True, + copy=False, + index=True, + default="draft", + tracking=True, + ) + currency_id = fields.Many2one( + comodel_name="res.currency", + default=lambda self: self.env.user.company_id.currency_id, + ) + forward_line_ids = fields.One2many( + comodel_name="budget.commit.forward.line", + inverse_name="forward_id", + string="Forward Lines", + readonly=True, + ) + missing_analytic = fields.Boolean( + compute="_compute_missing_analytic", + help="Not all forward lines has been assigned with carry forward analytic", + ) + _sql_constraints = [ + ("name_uniq", "UNIQUE(name)", "Name must be unique!"), + ] + + def _compute_missing_analytic(self): + for rec in self: + rec.missing_analytic = any( + rec.forward_line_ids.filtered_domain( + [("to_analytic_account_id", "=", False)] + ) + ) + + def _get_base_from_extension(self, res_model): + """For module extension""" + return "" + + def _get_base_domain_extension(self, res_model): + """For module extension""" + return "" + + def _get_name_model(self, res_model, need_replace=False): + return res_model.replace(".", "_") if need_replace else res_model + + def _get_commit_docline(self, res_model): + """Base domain for query""" + self.ensure_one() + model_name_db = self._get_name_model(res_model, need_replace=True) + query = """ + SELECT a.id + FROM %s a + %s + , jsonb_each_text(a.amount_commit) AS kv(key, value) + WHERE value::numeric != 0 AND a.date_commit < '%s' + AND (a.fwd_date_commit != '%s' OR a.fwd_date_commit is null) %s; + """ + query_string = query % ( + model_name_db, + self._get_base_from_extension(res_model), + self.to_date_commit, + self.to_date_commit, + self._get_base_domain_extension(res_model), + ) + # pylint: disable=sql-injection + self.env.cr.execute(query_string) + # Get all domain ids, remove duplicate from many analytics in 1 line + domain_ids = list({row["id"] for row in self.env.cr.dictfetchall()}) + model_name = self._get_name_model(res_model) + obj_ids = self.env[model_name].browse(domain_ids) + return obj_ids + + def _get_document_number(self, doc): + """For module extension""" + return False + + def _get_budget_docline_model(self): + """_compute_missing_analytic""" + self.ensure_one() + return [] + + def _prepare_vals_forward(self, docs, res_model): + self.ensure_one() + value_dict = [] + AnalyticAccount = self.env["account.analytic.account"] + for doc in docs: + analytic_account = ( + doc.fwd_analytic_distribution or doc[doc._budget_analytic_field] + ) + for analytic_id, aa_percent in analytic_account.items(): + method_type = False + analytic = AnalyticAccount.browse(int(analytic_id)) + if analytic.bm_date_to and analytic.bm_date_to < self.to_date_commit: + method_type = "new" + value_dict.append( + { + "forward_id": self.id, + "analytic_account_id": analytic_id, + "analytic_percent": aa_percent / 100, + "method_type": method_type, + "res_model": res_model, + "res_id": doc.id, + "document_id": "{},{}".format(doc._name, doc.id), + "document_number": self._get_document_number(doc), + "amount_commit": doc.amount_commit[str(analytic_id)], + "date_commit": doc.fwd_date_commit or doc.date_commit, + } + ) + return value_dict + + def action_review_budget_commit(self): + for rec in self: + for res_model in rec._get_budget_docline_model(): + rec.get_budget_commit_forward(res_model) + self.write({"state": "review"}) + + def action_filter_lines(self): + for rec in self: + rec.forward_line_ids = rec.filter_lines + + def get_budget_commit_forward(self, res_model): + """Get budget commitment forward for each new commit document type.""" + self = self.sudo() + Line = self.env["budget.commit.forward.line"] + for rec in self: + docs = rec._get_commit_docline(res_model) + vals = rec._prepare_vals_forward(docs, res_model) + Line.create(vals) + + def create_missing_analytic(self): + for rec in self: + for line in rec.forward_line_ids.filtered_domain( + [("to_analytic_account_id", "=", False)] + ): + line.to_analytic_account_id = ( + line.analytic_account_id.next_year_analytic() + ) + + def preview_budget_commit_forward_info(self): + self.ensure_one() + if self.missing_analytic: + raise UserError( + _( + "Some carry forward analytic accounts are missing.\n" + "Click 'Create Missing Analytics' button to create for next budget period." + ) + ) + wizard = self.env.ref("budget_control.view_budget_commit_forward_info_form") + domain = [ + ("forward_id", "=", self.id), + ("forward_id.state", "in", ["review", "done"]), + ] + forward_vals = self._get_forward_initial_commit(domain) + return { + "name": _("Preview Budget Commitment"), + "type": "ir.actions.act_window", + "view_mode": "form", + "res_model": "budget.commit.forward.info", + "views": [(wizard.id, "form")], + "view_id": wizard.id, + "target": "new", + "context": { + "default_forward_id": self.id, + "default_forward_info_line_ids": forward_vals, + }, + } + + def _get_forward_initial_commit(self, domain): + """Get analytic of all analytic accounts for this budget carry forward + + all the "done" budget carry forward""" + self.ensure_one() + forwards = self.env["budget.commit.forward.line"].read_group( + domain, + ["to_analytic_account_id", "amount_commit"], + ["to_analytic_account_id"], + orderby="to_analytic_account_id", + ) + res = [ + { + "analytic_account_id": f["to_analytic_account_id"][0], + "initial_commit": f["amount_commit"], + } + for f in forwards + ] + return res + + def _do_forward_commit(self, reverse=False): + """Create carry forward budget move to all related documents""" + self = self.sudo() + _analytic_field = "analytic_account_id" if reverse else "to_analytic_account_id" + for rec in self: + group_document = {} + # Group by document + for line in rec.forward_line_ids: + if line.document_id in group_document: + group_document[line.document_id].append(line) + else: + group_document[line.document_id] = [line] + for doc, fwd_line in group_document.items(): + # Convert to json + fwd_analytic_distribution = {} + for line in fwd_line: + fwd_analytic_distribution[str(line[_analytic_field].id)] = ( + line.analytic_percent * 100 + ) + doc.write( + { + "fwd_analytic_distribution": fwd_analytic_distribution, + "fwd_date_commit": reverse + and fwd_line[0].date_commit + or rec.to_date_commit, + } + ) + # For case extend + for line in rec.forward_line_ids: + if not reverse and line.method_type == "extend": + line.to_analytic_account_id.bm_date_to = ( + rec.to_budget_period_id.bm_date_to + ) + + def _do_update_initial_commit(self, reverse=False): + """Update all Analytic Account's initial commit value related to budget period""" + self.ensure_one() + # Reset initial when cancel document only + AnalyticAccount = self.env["account.analytic.account"] + domain = [("forward_id", "=", self.id)] + if reverse: + forward_vals = self._get_forward_initial_commit(domain) + for val in forward_vals: + analytic = AnalyticAccount.browse(val["analytic_account_id"]) + analytic.initial_commit -= val["initial_commit"] + return + forward_duplicate = self.env["budget.commit.forward"].search( + [ + ("to_budget_period_id", "=", self.to_budget_period_id.id), + ("state", "=", "done"), + ("id", "!=", self.id), + ] + ) + domain.append(("forward_id.state", "in", ["review", "done"])) + forward_vals = self._get_forward_initial_commit(domain) + for val in forward_vals: + analytic = AnalyticAccount.browse(val["analytic_account_id"]) + # Check first forward commit in the year, it should overwrite initial commit + if not forward_duplicate: + analytic.initial_commit = val["initial_commit"] + else: + analytic.initial_commit += val["initial_commit"] + + def _recompute_budget_move(self): + for rec in self: + # Recompute budget on document number + for document in list(set(rec.forward_line_ids.mapped("document_number"))): + document.recompute_budget_move() + + def action_budget_commit_forward(self): + self._do_forward_commit() + self.write({"state": "done"}) + self._do_update_initial_commit() + self._recompute_budget_move() + + def action_cancel(self): + forwards = self.env["budget.commit.forward"].search([("state", "=", "done")]) + max_date_commit = max(forwards.mapped("to_date_commit")) + # Not allow cancel document is past period. + if max_date_commit and any( + rec.to_date_commit < max_date_commit for rec in self + ): + raise UserError( + _("Unable to cancel this document as it belongs to a past period.") + ) + self.filtered(lambda l: l.state == "done")._do_forward_commit(reverse=True) + self.write({"state": "cancel"}) + self._do_update_initial_commit(reverse=True) + self._recompute_budget_move() + + def action_draft(self): + self.filtered(lambda l: l.state == "done")._do_forward_commit(reverse=True) + self.mapped("forward_line_ids").unlink() + self.write({"state": "draft"}) + self._do_update_initial_commit(reverse=True) + self._recompute_budget_move() + + +class BudgetCommitForwardLine(models.Model): + _name = "budget.commit.forward.line" + _description = "Budget Commit Forward Line" + _rec_names_search = ["document_number", "analytic_account_id"] + + forward_id = fields.Many2one( + comodel_name="budget.commit.forward", + string="Forward Commit", + index=True, + required=True, + readonly=True, + ondelete="cascade", + ) + analytic_account_id = fields.Many2one( + comodel_name="account.analytic.account", + index=True, + required=True, + readonly=True, + ) + analytic_percent = fields.Float( + readonly=True, + ) + method_type = fields.Selection( + selection=[ + ("new", "New"), + ("extend", "Extend"), + ], + string="Method", + help="New: if the analytic has ended, 'To Analytic Account' is required\n" + "Extended: if the analytic has ended, but want to extend to next period date end", + ) + to_analytic_account_id = fields.Many2one( + comodel_name="account.analytic.account", + string="Forward to Analytic", + compute="_compute_to_analytic_account_id", + store=True, + ) + bm_date_to = fields.Date( + compute="_compute_bm_date_to", + ) + res_model = fields.Selection( + selection=[], + required=True, + readonly=True, + ) + res_id = fields.Integer( + string="Res ID", + required=True, + readonly=True, + ) + document_id = fields.Reference( + selection=[], + string="Resource", + required=True, + readonly=True, + ) + document_number = fields.Reference( + selection=[], + string="Document", + required=True, + readonly=True, + ) + date_commit = fields.Date( + string="Date", + required=True, + readonly=True, + ) + currency_id = fields.Many2one( + related="forward_id.currency_id", + readonly=True, + ) + amount_commit = fields.Monetary( + string="Commitment", + required=True, + readonly=True, + ) + + @api.depends("analytic_account_id") + def _compute_bm_date_to(self): + for rec in self: + rec.bm_date_to = rec.analytic_account_id.bm_date_to + + @api.depends("method_type") + def _compute_to_analytic_account_id(self): + for rec in self: + # Case analytic has no end date, always use same analytic + if not rec.analytic_account_id.bm_date_to: + rec.to_analytic_account_id = rec.analytic_account_id + rec.method_type = False + continue + # Case analytic has extended end date that cover new commit date, use same analytic + if ( + rec.analytic_account_id.bm_date_to + and rec.analytic_account_id.bm_date_to >= rec.forward_id.to_date_commit + ): + rec.to_analytic_account_id = rec.analytic_account_id + rec.method_type = "extend" + continue + # Case want to extend analytic to end of next budget period + if rec.method_type == "extend": + rec.to_analytic_account_id = rec.analytic_account_id + continue + # Case want to use next analytic, if exists + if rec.method_type == "new": + rec.to_analytic_account_id = rec.analytic_account_id.next_year_analytic( + auto_create=False + ) + + def name_get(self): + return [ + ( + r.id, + "{document_number} - {analytic}".format( + document_number=r.document_number.display_name, + analytic=r.analytic_account_id.name, + ), + ) + for r in self + ] diff --git a/budget_control/models/budget_constraint.py b/budget_control/models/budget_constraint.py new file mode 100644 index 00000000..0b3fa668 --- /dev/null +++ b/budget_control/models/budget_constraint.py @@ -0,0 +1,25 @@ +# Copyright 2021 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class BudgetConstraint(models.Model): + _name = "budget.constraint" + _inherit = "mail.thread" + _description = "Constraint Budget by server action" + _order = "sequence" + + sequence = fields.Integer(default=1, required=True) + name = fields.Char(required=True) + description = fields.Text() + server_action_id = fields.Many2one( + comodel_name="ir.actions.server", + string="Server Action", + domain=[ + ("usage", "=", "ir_actions_server"), + ("model_id.model", "=", "budget.constraint"), + ], + help="Server action triggered as soon as this step is check_budget", + ) + active = fields.Boolean(default=True) diff --git a/budget_control/models/budget_control.py b/budget_control/models/budget_control.py new file mode 100644 index 00000000..c79e0f1a --- /dev/null +++ b/budget_control/models/budget_control.py @@ -0,0 +1,589 @@ +# Copyright 2020 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import UserError +from odoo.tools import float_compare + + +class BudgetControl(models.Model): + _name = "budget.control" + _description = "Budget Control" + _inherit = ["mail.thread", "mail.activity.mixin"] + _order = "analytic_account_id" + + name = fields.Char( + required=True, + readonly=True, + states={"draft": [("readonly", False)]}, + tracking=True, + ) + assignee_id = fields.Many2one( + comodel_name="res.users", + string="Assigned To", + domain=lambda self: [ + ( + "groups_id", + "in", + [self.env.ref("budget_control.group_budget_control_user").id], + ) + ], + tracking=True, + copy=False, + readonly=True, + states={"draft": [("readonly", False)]}, + ) + budget_period_id = fields.Many2one( + comodel_name="budget.period", + help="Budget Period that inline with date from/to", + ondelete="restrict", + readonly=True, + ) + date_from = fields.Date(related="budget_period_id.bm_date_from") + date_to = fields.Date(related="budget_period_id.bm_date_to") + active = fields.Boolean( + default=True, + ) + analytic_account_id = fields.Many2one( + comodel_name="account.analytic.account", + required=True, + readonly=True, + tracking=True, + ondelete="restrict", + ) + analytic_plan = fields.Many2one( + comodel_name="account.analytic.plan", + related="analytic_account_id.plan_id", + store=True, + ) + line_ids = fields.One2many( + comodel_name="budget.control.line", + inverse_name="budget_control_id", + string="Budget Lines", + copy=True, + context={"active_test": False}, + readonly=True, + states={ + "draft": [("readonly", False)], + "submit": [("readonly", False)], + }, + ) + plan_date_range_type_id = fields.Many2one( + comodel_name="date.range.type", + string="Plan Date Range", + required=True, + readonly=True, + states={"draft": [("readonly", False)]}, + ) + init_budget_commit = fields.Boolean( + string="Initial Budget By Commitment", + readonly=True, + states={"draft": [("readonly", False)]}, + help="If checked, the newly created budget control sheet will has " + "initial budget equal to current budget commitment of its year.", + ) + company_id = fields.Many2one( + comodel_name="res.company", + string="Company", + default=lambda self: self.env.company, + required=True, + readonly=True, + states={"draft": [("readonly", False)]}, + ) + currency_id = fields.Many2one( + comodel_name="res.currency", related="company_id.currency_id" + ) + allocated_amount = fields.Monetary( + string="Allocated", + help="Initial total amount for plan", + tracking=True, + readonly=True, + states={"draft": [("readonly", False)]}, + ) + released_amount = fields.Monetary( + string="Released", + compute="_compute_allocated_released_amount", + store=True, + tracking=True, + help="Total amount for transfer current", + ) + diff_amount = fields.Monetary( + compute="_compute_diff_amount", + help="Diff from Released - Budget", + ) + # Total Amount + amount_initial = fields.Monetary( + string="Initial Balance", + compute="_compute_initial_balance", + ) + amount_budget = fields.Monetary( + string="Budget", + compute="_compute_budget_info", + help="Sum of amount plan", + ) + amount_actual = fields.Monetary( + string="Actual", + compute="_compute_budget_info", + help="Sum of actual amount", + ) + amount_commit = fields.Monetary( + string="Commit", + compute="_compute_budget_info", + help="Total Commit = Sum of PR / PO / EX / AV commit (extension module)", + ) + amount_consumed = fields.Monetary( + string="Consumed", + compute="_compute_budget_info", + help="Consumed = Total Commitments + Actual", + ) + amount_balance = fields.Monetary( + string="Available", + compute="_compute_budget_info", + help="Available = Total Budget - Consumed", + ) + template_id = fields.Many2one( + comodel_name="budget.template", + related="budget_period_id.template_id", + readonly=True, + ) + use_all_kpis = fields.Boolean( + string="Use All KPIs", + readonly=True, + states={"draft": [("readonly", False)]}, + ) + template_line_ids = fields.Many2many( + string="KPIs", # Template line = 1 KPI, name for users + comodel_name="budget.template.line", + relation="budget_template_line_budget_contol_rel", + column1="budget_control_id", + column2="template_line_id", + domain="[('template_id', '=', template_id)]", + readonly=True, + states={"draft": [("readonly", False)]}, + ) + state = fields.Selection( + [ + ("draft", "Draft"), + ("submit", "Submitted"), + ("done", "Controlled"), + ("cancel", "Cancelled"), + ], + string="Status", + readonly=True, + copy=False, + index=True, + default="draft", + tracking=True, + ) + transfer_item_ids = fields.Many2many( + comodel_name="budget.transfer.item", + string="Transfers", + compute="_compute_transfer_item_ids", + ) + transferred_amount = fields.Monetary( + compute="_compute_transferred_amount", + ) + + @api.constrains("active", "state", "analytic_account_id", "budget_period_id") + def _check_budget_control_unique(self): + """Not allow multiple active budget control on same period""" + query = """ + SELECT analytic_account_id, budget_period_id, COUNT(*) + FROM budget_control + WHERE active = TRUE AND state != 'cancel' + AND analytic_account_id IN %s + AND budget_period_id IN %s + GROUP BY analytic_account_id, budget_period_id + """ + params = ( + tuple(self.mapped("analytic_account_id").ids), + tuple(self.mapped("budget_period_id").ids), + ) + self.env.cr.execute(query, params) + res = self.env.cr.dictfetchall() + analytic_ids = [x["analytic_account_id"] for x in res if x["count"] > 1] + if analytic_ids: + analytics = self.env["account.analytic.account"].browse(analytic_ids) + raise UserError( + _("Multiple budget control on the same period for: %s") + % ", ".join(analytics.mapped("name")) + ) + + @api.depends("analytic_account_id") + def _compute_initial_balance(self): + for rec in self: + rec.amount_initial = ( + rec.analytic_account_id.initial_available + + rec.analytic_account_id.initial_commit + ) + + @api.constrains("line_ids") + def _check_budget_control_over_consumed(self): + BudgetPeriod = self.env["budget.period"] + if self.env.context.get("edit_amount", False): + return + for rec in self.filtered( + lambda l: l.budget_period_id.control_level == "analytic_kpi" + ): + for line in rec.line_ids: + # Filter according to budget_control parameter + query, dataset_all = rec.with_context( + filter_kpi_ids=[line.kpi_id.id] + )._get_query_dataset_all() + # Get data from dataset + budget_info = BudgetPeriod.get_budget_info_from_dataset( + query, dataset_all + ) + if budget_info["amount_balance"] < 0: + raise UserError( + _( + "Total amount in KPI {line_name} will result in {amount:,.2f}" + ).format( + line_name=line.name, amount=budget_info["amount_balance"] + ) + ) + + @api.onchange("use_all_kpis") + def _onchange_use_all_kpis(self): + if self.use_all_kpis: + self.template_line_ids = self.template_id.line_ids + else: + self.template_line_ids = False + + def action_confirm_state(self): + return { + "name": _("Confirmation"), + "type": "ir.actions.act_window", + "res_model": "budget.state.confirmation", + "view_mode": "form", + "target": "new", + "context": self._context, + } + + @api.depends("allocated_amount") + def _compute_allocated_released_amount(self): + for rec in self: + rec.released_amount = rec.allocated_amount + rec.transferred_amount + + @api.depends("released_amount", "amount_budget") + def _compute_diff_amount(self): + for rec in self: + rec.diff_amount = rec.released_amount - rec.amount_budget + + def _filter_by_budget_control(self, val): + return ( + val["analytic_account_id"][0] == self.analytic_account_id.id + and val["budget_period_id"][0] == self.budget_period_id.id + ) + + def _get_domain_dataset_all(self): + """Retrieve budgeting data for a list of budget_control""" + analytic_ids = self.mapped("analytic_account_id").ids + budget_period_ids = self.mapped("budget_period_id").ids + domain = [ + ("analytic_account_id", "in", analytic_ids), + ("budget_period_id", "in", budget_period_ids), + ] + # Optional filters by context + if self.env.context.get("no_fwd_commit"): + domain.append(("fwd_commit", "=", False)) + if self.env.context.get("filter_kpi_ids"): + domain.append(("kpi_id", "in", self.env.context.get("filter_kpi_ids"))) + return domain + + def _get_context_monitoring(self): + """Support for add context in monitoring""" + return self.env.context.copy() + + def _get_query_dataset_all(self): + BudgetPeriod = self.env["budget.period"] + MonitorReport = self.env["budget.monitor.report"] + ctx = self._get_context_monitoring() + query = BudgetPeriod._budget_info_query() + domain = self._get_domain_dataset_all() + dataset_all = MonitorReport.with_context(**ctx).read_group( + domain=domain, + fields=query["fields"], + groupby=query["groupby"], + lazy=False, + ) + return query, dataset_all + + def _compute_budget_info(self): + BudgetPeriod = self.env["budget.period"] + query, dataset_all = self._get_query_dataset_all() + for rec in self: + # Filter according to budget_control parameter + dataset = [x for x in dataset_all if rec._filter_by_budget_control(x)] + # Get data from dataset + budget_info = BudgetPeriod.get_budget_info_from_dataset(query, dataset) + rec.update(budget_info) + + def _get_lines_init_date(self): + self.ensure_one() + init_date = min(self.line_ids.mapped("date_from")) + return self.line_ids.filtered(lambda l: l.date_from == init_date) + + def do_init_budget_commit(self, init): + """Initialize budget with current commitment amount.""" + for bc in self: + bc.update({"init_budget_commit": init}) + if not init or not bc.init_budget_commit or not bc.line_ids: + continue + min(bc.line_ids.mapped("date_from")) + lines = bc._get_lines_init_date() + for line in lines: + query_data = bc.budget_period_id._get_budget_avaiable( + bc.analytic_account_id.id, line.template_line_id + ) + # Get init commit amount only + balance_commit = sum( + q["amount"] + for q in query_data + if q["amount"] is not None + and q["amount_type"] not in ["1_budget", "8_actual"] + ) + line.update({"amount": abs(balance_commit)}) + + @api.onchange("init_budget_commit") + def _onchange_init_budget_commit(self): + self.do_init_budget_commit(self.init_budget_commit) + + def _check_budget_amount(self): + for rec in self: + # Check plan vs released + if ( + float_compare( + rec.amount_budget, + rec.released_amount, + precision_rounding=rec.currency_id.rounding, + ) + != 0 + ): + raise UserError( + _( + "Planning amount should equal to the " + "released amount {amount:,.2f} {symbol}" + ).format(amount=rec.released_amount, symbol=rec.currency_id.symbol) + ) + # Check plan vs intial + if ( + float_compare( + rec.amount_initial, + rec.amount_budget, + precision_rounding=rec.currency_id.rounding, + ) + == 1 + ): + raise UserError( + _( + "Planning amount should be greater than " + "initial balance {amount:,.2f} {symbol}" + ).format(amount=rec.amount_initial, symbol=rec.currency_id.symbol) + ) + + def action_draft(self): + return self.write({"state": "draft"}) + + def action_submit(self): + self._check_budget_amount() + return self.write({"state": "submit"}) + + def action_done(self): + self._check_budget_amount() + return self.write({"state": "done"}) + + def action_cancel(self): + return self.write({"state": "cancel"}) + + def _domain_template_line(self): + return [("id", "in", self.template_line_ids.ids)] + + def _get_dict_budget_lines(self, date_range, template_line): + return { + "template_line_id": template_line.id, + "date_range_id": date_range.id, + "date_from": date_range.date_start, + "date_to": date_range.date_end, + "analytic_account_id": self.analytic_account_id.id, + "budget_control_id": self.id, + } + + def _get_budget_lines(self, date_range, template_line): + self.ensure_one() + dict_value = self._get_dict_budget_lines(date_range, template_line) + if self._context.get("keep_item_amount", False): + # convert dict to list + domain_item = [(k, "=", v) for k, v in dict_value.items()] + item = self.line_ids.search(domain_item, limit=1) + dict_value["amount"] = item.amount + return dict_value + + def _keep_item_amount(self, vals, old_items): + """Find amount from old plan for update new plan""" + for val in vals: + domain_item = [(k, "=", v) for k, v in val.items()] + item = old_items.search(domain_item) + val["amount"] = item.amount + + def prepare_budget_control_matrix(self): + BudgetTemplateLine = self.env["budget.template.line"] + DateRange = self.env["date.range"] + for bc in self: + if not bc.plan_date_range_type_id: + raise UserError(_("Please select range")) + template_lines = BudgetTemplateLine.search(bc._domain_template_line()) + date_ranges = DateRange.search( + [ + ("type_id", "=", bc.plan_date_range_type_id.id), + ("date_start", ">=", bc.date_from), + ("date_end", "<=", bc.date_to), + ] + ) + items = [] + for date_range in date_ranges: + items += [ + bc._get_budget_lines(date_range, template_line) + for template_line in template_lines + ] + # Delete the existing budget lines + bc.line_ids.unlink() + # Create the new budget lines and Reset the carry over budget + bc.write( + { + "init_budget_commit": False, + "line_ids": [(0, 0, val) for val in items], + } + ) + + def _get_domain_budget_monitoring(self): + return [("analytic_account_id", "=", self.analytic_account_id.id)] + + def _get_context_budget_monitoring(self): + ctx = {"search_default_group_by_analytic_account": 1} + return ctx + + def action_view_monitoring(self): + self.ensure_one() + ctx = self._get_context_budget_monitoring() + domain = self._get_domain_budget_monitoring() + return { + "name": _("Budget Monitoring"), + "res_model": "budget.monitor.report", + "view_mode": "pivot,tree,graph", + "domain": domain, + "context": ctx, + "type": "ir.actions.act_window", + } + + def _get_domain_transfer_item_ids(self): + self.ensure_one() + return [ + ("state", "=", "transfer"), + "|", + ("budget_control_from_id", "=", self.id), + ("budget_control_to_id", "=", self.id), + ] + + def _compute_transfer_item_ids(self): + TransferItem = self.env["budget.transfer.item"] + for rec in self: + items = TransferItem.search(rec._get_domain_transfer_item_ids()) + rec.transfer_item_ids = items + + @api.depends("transfer_item_ids") + def _compute_transferred_amount(self): + for rec in self: + # Get the transfer items where the current budget control is the source + from_transfer_items = rec.transfer_item_ids.filtered( + lambda l: l.budget_control_from_id == rec + ) + # Get the transfer items where the current budget control is the destination + to_transfer_items = rec.transfer_item_ids - from_transfer_items + # Calculate the total transferred amount by subtracting the amount transferred + total_amount = sum(to_transfer_items.mapped("amount")) - sum( + from_transfer_items.mapped("amount") + ) + rec.transferred_amount = total_amount + + def action_open_budget_transfer_item(self): + self.ensure_one() + ctx = self.env.context.copy() + ctx.update({"create": False, "edit": False}) + items = self.transfer_item_ids + list_view = self.env.ref("budget_control.view_budget_transfer_item_ref_tree").id + form_view = self.env.ref("budget_control.view_budget_transfer_item_ref_form").id + return { + "name": _("Budget Transfer Items"), + "type": "ir.actions.act_window", + "res_model": "budget.transfer.item", + "views": [[list_view, "list"], [form_view, "form"]], + "view_mode": "list", + "context": ctx, + "domain": [("id", "in", items and items.ids or [])], + } + + +class BudgetControlLine(models.Model): + _name = "budget.control.line" + _description = "Budget Control Lines" + _order = "date_range_id, kpi_id" + + budget_control_id = fields.Many2one( + comodel_name="budget.control", + ondelete="cascade", + index=True, + required=True, + ) + name = fields.Char(compute="_compute_name", required=False, readonly=True) + date_range_id = fields.Many2one( + comodel_name="date.range", + string="Date range", + ) + date_from = fields.Date(required=True, string="From") + date_to = fields.Date(required=True, string="To") + analytic_account_id = fields.Many2one( + comodel_name="account.analytic.account", string="Analytic account" + ) + amount = fields.Float() + template_line_id = fields.Many2one( + comodel_name="budget.template.line", + index=True, + ) + kpi_id = fields.Many2one( + comodel_name="budget.kpi", + related="template_line_id.kpi_id", + store=True, + ) + active = fields.Boolean( + compute="_compute_active", + readonly=True, + store=True, + ) + state = fields.Selection( + [ + ("draft", "Draft"), + ("submit", "Submitted"), + ("done", "Controlled"), + ("cancel", "Cancelled"), + ], + string="Status", + compute="_compute_budget_control_state", + store=True, + index=True, + ) + + @api.depends("kpi_id") + def _compute_name(self): + for rec in self: + rec.name = rec.kpi_id.display_name + + @api.depends("budget_control_id.state") + def _compute_budget_control_state(self): + for rec in self: + rec.state = rec.budget_control_id.state + + @api.depends("budget_control_id.active") + def _compute_active(self): + for rec in self: + rec.active = rec.budget_control_id.active if rec.budget_control_id else True diff --git a/budget_control/models/budget_kpi.py b/budget_control/models/budget_kpi.py new file mode 100644 index 00000000..04766897 --- /dev/null +++ b/budget_control/models/budget_kpi.py @@ -0,0 +1,11 @@ +# Copyright 2022 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class BudgetKPI(models.Model): + _name = "budget.kpi" + _description = "Budget KPI" + + name = fields.Char(required=True) diff --git a/budget_control/models/budget_move_adjustment.py b/budget_control/models/budget_move_adjustment.py new file mode 100644 index 00000000..71f83518 --- /dev/null +++ b/budget_control/models/budget_move_adjustment.py @@ -0,0 +1,194 @@ +# Copyright 2020 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class BudgetMoveAdjustment(models.Model): + _name = "budget.move.adjustment" + _inherit = ["mail.thread"] + _description = "Budget Moves Adjustment" + + budget_move_ids = fields.One2many( + comodel_name="account.budget.move", + inverse_name="adjust_id", + string="Account Budget Moves", + ) + name = fields.Char( + default="/", + index=True, + copy=False, + required=True, + readonly=True, + ) + description = fields.Text( + readonly=True, + states={"draft": [("readonly", False)]}, + tracking=True, + ) + adjust_item_ids = fields.One2many( + comodel_name="budget.move.adjustment.item", + inverse_name="adjust_id", + readonly=True, + states={"draft": [("readonly", False)]}, + tracking=True, + ) + date_commit = fields.Date( + string="Budget Commit Date", + required=True, + readonly=True, + states={"draft": [("readonly", False)]}, + tracking=True, + ) + currency_id = fields.Many2one( + comodel_name="res.currency", + default=lambda self: self.env.user.company_id.currency_id, + ) + state = fields.Selection( + [ + ("draft", "Draft"), + ("done", "Adjusted"), + ("cancel", "Cancelled"), + ], + string="Status", + default="draft", + tracking=True, + ) + + @api.model_create_multi + def create(self, vals_list): + """Generate a new name using the 'budget.move.adjustment' sequence""" + for vals in vals_list: + if vals.get("name", "/") == "/": + vals["name"] = ( + self.env["ir.sequence"].next_by_code("budget.move.adjustment") + or "/" + ) + return super().create(vals_list) + + def unlink(self): + """Check that only records with state 'draft' can be deleted.""" + if any(rec.state != "draft" for rec in self): + raise UserError( + _("You are trying to delete a record that is still referenced!") + ) + return super().unlink() + + def action_draft(self): + self.write({"state": "draft"}) + + def action_cancel(self): + self.write({"state": "cancel"}) + + def action_adjust(self): + res = self.write({"state": "done"}) + BudgetPeriod = self.env["budget.period"] + for doc in self: + BudgetPeriod.check_budget(doc.adjust_item_ids) + return res + + def recompute_budget_move(self): + self.mapped("adjust_item_ids").recompute_budget_move() + + def close_budget_move(self): + self.mapped("adjust_item_ids").close_budget_move() + + def write(self, vals): + """ + - Commit budget when state changes to done + - Cancel/Draft document should delete all budget commitment + """ + res = super().write(vals) + if vals.get("state") in ("done", "cancel", "draft"): + doclines = self.mapped("adjust_item_ids") + if vals.get("state") in ("cancel", "draft"): + doclines.write({"date_commit": False}) + doclines.recompute_budget_move() + return res + + +class BudgetMoveAdjustmentItem(models.Model): + _name = "budget.move.adjustment.item" + _inherit = ["budget.docline.mixin"] + _description = "Budget Moves Adjustment Lines" + _budget_date_commit_fields = ["adjust_id.date_commit"] + _budget_move_model = "account.budget.move" + _budget_analytic_field = "analytic_account_id" + _doc_rel = "adjust_id" + + adjust_id = fields.Many2one( + comodel_name="budget.move.adjustment", + ondelete="cascade", + index=True, + ) + name = fields.Char(string="Description") + budget_move_ids = fields.One2many( + comodel_name="account.budget.move", + inverse_name="adjust_item_id", + string="Account Budget Moves", + ) + adjust_type = fields.Selection( + selection=[ + ("consume", "Consume"), + ("release", "Release"), + ], + default="consume", + required=True, + help="* Consume: Decrease budget of selected analtyic\n" + "* Release: Increase budget of selected analtyic", + ) + product_id = fields.Many2one( + comodel_name="product.product", + ) + account_id = fields.Many2one( + comodel_name="account.account", + required=True, + ) + analytic_account_id = fields.Many2one( + comodel_name="account.analytic.account", + string="Analytic Account", + required=True, + index=True, + ) + currency_id = fields.Many2one( + related="adjust_id.currency_id", + readonly=True, + ) + amount = fields.Monetary( + help="Amount as per company currency", + ) + + @api.onchange("product_id") + def _onchange_product_id(self): + self.account_id = self.product_id._get_product_accounts()["expense"] + self.name = self.product_id.name + + @api.depends("amount") + def _compute_amount_balance(self): + if self.filtered(lambda l: l.amount <= 0): + raise UserError(_("Given amount must be positive")) + for rec in self: + # If the adjust type is 'release', negate the amount, else leave it as is + rec.amount = -rec.amount if rec.adjust_type == "release" else rec.amount + + def recompute_budget_move(self): + for item in self: + item.budget_move_ids.unlink() + item.commit_budget() + + def _init_docline_budget_vals(self, budget_vals, analytic_id): + self.ensure_one() + budget_vals["amount_currency"] = ( + -self.amount if self.adjust_type == "release" else self.amount + ) + # Document specific values + budget_vals.update( + { + "adjust_item_id": self.id, + } + ) + return super()._init_docline_budget_vals(budget_vals, analytic_id) + + def _valid_commit_state(self): + return self.adjust_id.state == "done" diff --git a/budget_control/models/budget_period.py b/budget_control/models/budget_period.py new file mode 100644 index 00000000..6d9105dc --- /dev/null +++ b/budget_control/models/budget_period.py @@ -0,0 +1,542 @@ +# Copyright 2020 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from psycopg2 import sql + +from odoo import _, api, fields, models +from odoo.exceptions import RedirectWarning, UserError, ValidationError +from odoo.tools import float_compare, format_amount + + +class BudgetPeriod(models.Model): + _name = "budget.period" + _description = "For each fiscal year, manage how budget is controlled" + + name = fields.Char(required=True) + bm_date_from = fields.Date( + string="Date From", + required=True, + ) + bm_date_to = fields.Date( + string="Date To", + required=True, + ) + template_id = fields.Many2one( + comodel_name="budget.template", + string="Budget Template", + ondelete="restrict", + required=True, + ) + control_budget = fields.Boolean( + help="Block document transaction if budget is not enough", + ) + account = fields.Boolean( + string="On Account", + compute="_compute_control_account", + store=True, + readonly=False, + help="Control budget on journal document(s), i.e., vendor bill", + ) + control_all_analytic_accounts = fields.Boolean( + string="Control All Analytics", + default=True, + ) + control_analytic_account_ids = fields.Many2many( + comodel_name="account.analytic.account", + relation="budget_period_analytic_account_rel", + string="Controlled Analytics", + ) + control_level = fields.Selection( + selection=[ + ("analytic", "Analytic"), + ("analytic_kpi", "Analytic & KPI"), + ], + string="Level of Control", + required=True, + default="analytic", + help="Level of budget check.\n" + "1. Based on Analytic Account only\n" + "2. Based on Analytic Account & KPI (more fine granied)", + ) + plan_date_range_type_id = fields.Many2one( + comodel_name="date.range.type", + string="Plan Date Range", + required=True, + help="Budget control sheet in this budget control year, will use this " + "data range to plan the budget.", + ) + analytic_ids = fields.One2many( + comodel_name="account.analytic.account", + inverse_name="budget_period_id", + ) + + @api.model + def default_get(self, field_list): + res = super().default_get(field_list) + res["template_id"] = self.env.company.budget_template_id.id + return res + + @api.depends("control_budget") + def _compute_control_account(self): + for rec in self: + rec.account = rec.control_budget + + def _check_budget_period_date_range(self): + self.ensure_one() + range_from = self.env["date.range"].search( + [ + ("date_start", "<=", self.bm_date_from), + ("date_end", ">=", self.bm_date_from), + ] + ) + range_to = self.env["date.range"].search( + [ + ("date_start", "<=", self.bm_date_to), + ("date_end", ">=", self.bm_date_to), + ] + ) + if not range_from or not range_to: + action = self.env.ref("date_range.date_range_generator_action") + msg = ( + _( + "There are no date ranges for the budget period, %s, yet.\n" + "Please create date ranges that will cover this budget period." + ) + % self.display_name + ) + raise RedirectWarning(msg, action.id, _("Generate date range now")) + + def action_view_budget_control(self): + """View all budget.control sharing same budget period.""" + self.ensure_one() + action = self.env["ir.actions.act_window"]._for_xml_id( + "budget_control.budget_control_action" + ) + budget_controls = self.env["budget.control"].search( + [("budget_period_id", "=", self.id)] + ) + action.update( + { + "domain": [("id", "in", budget_controls.ids)], + } + ) + return action + + @api.model + def check_budget_constraint(self, budget_constraints, doclines): + error_messages = [] + for budget_constraint in budget_constraints: + # Run the server action associated with the budget constraint. + # If it returns any error messages, add them to the list. + msg_error = ( + budget_constraint.server_action_id.with_context( + active_model=budget_constraint._name, + active_id=budget_constraint.id, + doclines=doclines, + ) + .sudo() + .run() + ) + if msg_error: + error_messages.extend(msg_error) + else: + # If the loop completed without being interrupted, raise a UserError + # with the concatenated error messages. + if error_messages: + raise UserError("\n".join(error_messages)) + return True + + def _get_budget_constraint(self): + return self.env["budget.constraint"].search( + [("active", "=", True)], order="sequence" + ) + + @api.model + def check_budget(self, doclines, doc_type="account"): + """ + Check the budget based on the input budget moves, i.e., account_move_line. + 1. Get a valid budget period (how budget is being controlled). + 2. Determine which account (KPI) and analytic to control based on (1) and doclines. + 3. Check for negative budget and return warnings based on (2) and the KPI matrix. + """ + if self._context.get("force_no_budget_check"): + return + doclines = doclines.filtered("can_commit") + if not doclines: + return + self = self.sudo() + budget_constraints = self._get_budget_constraint() + all_analytics = doclines.mapped(doclines._budget_analytic_field) + # Get All Analytic Account + if doclines._budget_analytic_field == "analytic_distribution": + all_analytic_ids = set() + for data_dict in all_analytics: + # Check percent analytic account must be 100% only + total_sum = sum(data_dict.values()) + if ( + float_compare( + total_sum, + 100.0, + precision_rounding=2, + ) + != 0 + ): + raise UserError( + _( + "The total sum percent of Analytic Account must 100%. " + "Please check again." + ) + ) + all_analytic_ids.update(int(key) for key in data_dict.keys()) + else: + all_analytic_ids = all_analytics + # Check budget by group analytic. For case many budget periods in one document. + for aa in all_analytic_ids: + if isinstance(aa, int): + doclines = doclines.filtered( + lambda l: l[doclines._budget_analytic_field].get(str(aa)) + ) + else: + doclines = doclines.filtered( + lambda l: l[doclines._budget_analytic_field] == aa + ) + # Find active budget.period based on latest doclines date_commit + date_commit = doclines.filtered("date_commit").mapped("date_commit") + if not date_commit: + return + date_commit = max(date_commit) + budget_period = self._get_eligible_budget_period( + date_commit, doc_type=doc_type + ) + if not budget_period: + return + # Find combination of account (KPI) + analytic (i.e., project) to control + controls = self._prepare_controls(budget_period, doclines) + if not controls: + return + # The budget_control of these analytics must be active + if isinstance(aa, int): + analytic_ids = all_analytic_ids + else: + analytic_ids = [x["analytic_id"] for x in controls] + analytics = self.env["account.analytic.account"].browse(analytic_ids) + analytics._check_budget_control_status(budget_period_id=budget_period.id) + # Check budget on each control element against each KPI/avail (period) + currency = ( + "currency_id" in doclines + and doclines.mapped("currency_id")[:1] + or self.env.context.get("doc_currency", self.env.company.currency_id) + ) + warnings = self.with_context( + date_commit=date_commit, doc_currency=currency, doclines=doclines + )._check_budget_available(controls, budget_period) + if warnings: + msg = "\n".join(["Budget not sufficient,", "\n".join(warnings)]) + raise UserError(msg) + # Check budget constraint following your customize condition + elif doclines and budget_constraints and budget_period: + self.check_budget_constraint(budget_constraints, doclines) + return + + @api.model + def check_budget_precommit(self, doclines, doc_type="account"): + """Precommit check, + first do the normal commit, do checking, and remove commits""" + if not doclines: + return + doclines = doclines.sudo() + # Allow precommit budget with related origin document (PO) + budget_moves_uncommit = False + if doc_type == "account": + budget_moves_uncommit = doclines.with_context( + force_commit=True + ).uncommit_purchase_budget() + # Commit budget + budget_moves = [] + vals_date_commit = [] + for line in doclines: + if not line.date_commit: + vals_date_commit.append(line.id) + budget_move = line.with_context(force_commit=True).commit_budget() + if budget_move: + budget_moves.append(budget_move) + # Check Budget + self.env["budget.period"].check_budget(doclines, doc_type=doc_type) + # Remove commits + for budget_move in budget_moves: + budget_move.unlink() + # Delete date commit from system create auto only + doclines.filtered(lambda l: l.id in vals_date_commit).write( + {"date_commit": False} + ) + # Remove uncommit budget + if budget_moves_uncommit: + budget_moves_uncommit.unlink() + + @api.model + def check_over_returned_budget(self, docline, reverse=False): + self = self.sudo() + doc = docline[docline._doc_rel] + budget_moves = doc[docline._budget_field()] + credit = sum(budget_moves.mapped("credit")) + debit = sum(budget_moves.mapped("debit")) + amount_credit = debit if reverse else credit + amount_debit = credit if reverse else debit + # For now, when any over returned budget, make immediate adjustment + if float_compare(amount_credit, amount_debit, 2) == 1: + docline.with_context( + use_amount_commit=True, + commit_note=_("Over returned auto adjustment, %s") + % docline.display_name, + adj_commit=True, + ).commit_budget(reverse=True) + + @api.model + def _get_eligible_budget_period(self, date=False, doc_type=False): + """ + Get the eligible budget period based on the specified date and document type. + """ + if not date: + date = fields.Date.context_today(self) + BudgetPeriod = self.env["budget.period"] + budget_period = BudgetPeriod.search( + [("bm_date_from", "<=", date), ("bm_date_to", ">=", date)] + ) + if budget_period and len(budget_period) > 1: + raise ValidationError( + _( + "Multiple Budget Periods found for date %s.\nPlease ensure " + "there is only one Budget Period valid for this date." + ) + % date + ) + if not doc_type: + return budget_period + # Get period control budget. + # if doctype is account, check special control too. + if doc_type == "account": + return budget_period.filtered( + lambda l: (l.control_budget and l.account) + or (not l.control_budget and l.account) + ) + # Other module control budget must hook it for filter + return budget_period + + @api.model + def _prepare_controls(self, budget_period, doclines): + controls = set() + control_analytics = budget_period.control_analytic_account_ids + budget_moves = doclines.mapped(doclines._budget_field()) + # Get budget moves from the period only + budget_moves_period = budget_moves.filtered( + lambda l: l.date >= budget_period.bm_date_from + and l.date <= budget_period.bm_date_to + ) + need_control = self.env.context.get("need_control") + for budget_move in budget_moves_period: + if budget_period.control_all_analytic_accounts: + if ( + budget_move.analytic_account_id + and budget_move[budget_move._budget_control_field] + ): + controls.add( + ( + budget_move.analytic_account_id.id, + budget_move[budget_move._budget_control_field].id, + ) + ) + else: # analytic in control or force control by send context + if ( + budget_move.analytic_account_id in control_analytics + and budget_move[budget_move._budget_control_field] + ) or need_control: + controls.add( + ( + budget_move.analytic_account_id.id, + budget_move[budget_move._budget_control_field].id, + ) + ) + # Convert to list of dicts for readability + return [ + {"analytic_id": x[0], budget_move._budget_control_field: x[1]} + for x in controls + ] + + def _get_filter_template_line(self, all_template_lines, control): + account_id = control["account_id"] + template_lines = all_template_lines.filtered( + lambda l: account_id in l.account_ids.ids + ) + return template_lines + + @api.model + def _get_kpi_by_control_key(self, template_lines, control): + """ + By default, control key is account_id as it can be used to get KPI + In future, this can be other key, i.e., activity_id based on installed module + """ + account_id = control["account_id"] + template_line = self._get_filter_template_line(template_lines, control) + if len(template_line) == 1: + return template_line + # Invalid Template Lines + account = self.env["account.account"].browse(account_id) + if not template_line: + raise UserError( + _("Chosen account code %s is not valid in template") + % account.display_name + ) + raise UserError( + _( + "Template Lines has more than one KPI being " + "referenced by the same account code %s" + ) + % (account.display_name) + ) + + def _get_where_domain(self, analytic_id, template_lines): + """Return the WHERE clause for the budget monitoring query.""" + if ( + not template_lines + or self._context.get("control_level", False) == "analytic" + ): + return "analytic_account_id = {}".format(analytic_id) + kpi_domain = ( + "= {}".format(template_lines.kpi_id.id) + if len(template_lines) == 1 + else "in {}".format(tuple(template_lines.kpi_id.ids)) + ) + return "analytic_account_id = {} and kpi_id {}".format(analytic_id, kpi_domain) + + def _get_budget_monitor_report(self): + """Hook for add context""" + return self.env["budget.monitor.report"] + + def _get_budget_avaiable(self, analytic_id, template_lines): + self._cr.execute( + sql.SQL( + """SELECT * FROM ({monitoring}) report + WHERE {where_domain}""".format( + monitoring=self._get_budget_monitor_report()._table_query, + where_domain=self._get_where_domain(analytic_id, template_lines), + ) + ) + ) + return self.env.cr.dictfetchall() + + def _get_balance_currency(self, company, balance, doc_currency, date_commit): + """Convert balance to balance currency (multi-currency)""" + return company.currency_id._convert(balance, doc_currency, company, date_commit) + + @api.model + def _check_budget_available(self, controls, budget_period): + """ + This function is a CORE function, please modify carefully + Author: Kitti U., Saran Lim. + """ + warnings = [] + Analytic = self.env["account.analytic.account"] + template_lines = all_template_lines = budget_period.template_id.line_ids + company = self.env.user.company_id + doc_currency = self.env.context.get("doc_currency") + date_commit = self.env.context.get("date_commit") + for control in controls: + analytic_id = control["analytic_id"] + # Get the KPI(s) to check the budget, + # in case the control level is set to "analytic_kpi" + if budget_period.control_level == "analytic_kpi": + template_lines = self._get_filter_template_line( + all_template_lines, control + ) + # Get the available budget for the specified analytic account and KPI(s) + query_data = self.with_context( + control_level=budget_period.control_level + )._get_budget_avaiable(analytic_id, template_lines) + # Check kpi not valid for budgeting when control level analytic & kpi + if budget_period.control_level == "analytic_kpi" and not query_data: + raise UserError( + _("Chosen KPI %s is not valid for budgeting") + % template_lines.display_name + ) + balance = sum( + q["amount"] + for q in query_data + if q["amount"] is not None and q["budget_period_id"] == budget_period.id + ) + # Show a warning if the budget is not sufficient + if float_compare(balance, 0.0, precision_rounding=2) == -1: + # Convert the balance to the document currency + balance_currency = self._get_balance_currency( + company, balance, doc_currency, date_commit + ) + fomatted_balance = format_amount( + self.env, balance_currency, doc_currency + ) + analytic_name = Analytic.browse(analytic_id).display_name + if budget_period.control_level == "analytic_kpi": + analytic_name = "{} & {}".format( + template_lines.display_name, analytic_name + ) + warnings.append( + _("{analytic_name}, will result in {formatted_balance}").format( + analytic_name=analytic_name, formatted_balance=fomatted_balance + ) + ) + return list(set(warnings)) + + @api.model + def get_budget_info_from_dataset(self, query, dataset): + """Get budget overview from a budget monitor dataset, i.e., + budget_info = { + "amount_budget": 100, + "amount_actual": 70, + "amount_balance": 30 + } + Note: based on installed modules + """ + budget_info = {col: 0 for col in query["info_cols"].keys()} + budget_info["amount_commit"] = 0 + for col, (amount_type, is_commit) in query["info_cols"].items(): + info = list(filter(lambda l: l["amount_type"] == amount_type, dataset)) + if len(info) > 1: + raise ValidationError(_("Error retrieving budget info!")) + if not info: + continue + amount = info[0]["amount"] + if is_commit: + budget_info[col] = -amount # Negate + budget_info["amount_commit"] += budget_info[col] + elif amount_type == "8_actual": # Negate consumed + budget_info[col] = -amount + else: + budget_info[col] = amount + budget_info["amount_consumed"] = ( + budget_info["amount_commit"] + budget_info["amount_actual"] + ) + budget_info["amount_balance"] = ( + budget_info["amount_budget"] - budget_info["amount_consumed"] + ) + return budget_info + + def _budget_info_query(self): + query = { + "info_cols": { + "amount_budget": ( + "1_budget", + False, + ), # (amount_type, is_commit) + "amount_actual": ("8_actual", False), + }, + "fields": [ + "analytic_account_id", + "budget_period_id", + "amount_type", + "amount", + ], + "groupby": [ + "analytic_account_id", + "budget_period_id", + "amount_type", + ], + } + return query diff --git a/budget_control/models/budget_template.py b/budget_control/models/budget_template.py new file mode 100644 index 00000000..52cc9f2f --- /dev/null +++ b/budget_control/models/budget_template.py @@ -0,0 +1,50 @@ +# Copyright 2022 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class BudgetTemplate(models.Model): + _name = "budget.template" + _description = "Budget Template" + + name = fields.Char(required=True) + line_ids = fields.One2many( + comodel_name="budget.template.line", + inverse_name="template_id", + ) + + +class BudgetTemplateLine(models.Model): + _name = "budget.template.line" + _description = "Budget Template Lines" + + template_id = fields.Many2one( + comodel_name="budget.template", + index=True, + ondelete="cascade", + readonly=True, + ) + name = fields.Char( + compute="_compute_name", + store=True, + ) + kpi_id = fields.Many2one( + comodel_name="budget.kpi", + string="KPI", + required=True, + ondelete="restrict", + index=True, + ) + account_ids = fields.Many2many( + comodel_name="account.account", + relation="budget_kpi_account_rel", + column1="budget_kpi_id", + column2="account_id", + required=True, + ) + + @api.depends("kpi_id") + def _compute_name(self): + for rec in self: + rec.name = rec.kpi_id.name diff --git a/budget_control/models/budget_transfer.py b/budget_control/models/budget_transfer.py new file mode 100644 index 00000000..ed0c56cd --- /dev/null +++ b/budget_control/models/budget_transfer.py @@ -0,0 +1,106 @@ +# Copyright 2020 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError + + +class BudgetTransfer(models.Model): + _name = "budget.transfer" + _inherit = ["mail.thread"] + _description = "Budget Transfer" + + name = fields.Char( + default="/", + index=True, + copy=False, + required=True, + readonly=True, + ) + budget_period_id = fields.Many2one( + comodel_name="budget.period", + string="Budget Year", + default=lambda self: self.env["budget.period"]._get_eligible_budget_period(), + required=True, + readonly=True, + ) + transfer_item_ids = fields.One2many( + comodel_name="budget.transfer.item", + inverse_name="transfer_id", + readonly=True, + copy=True, + states={"draft": [("readonly", False)]}, + ) + state = fields.Selection( + [ + ("draft", "Draft"), + ("submit", "Submitted"), + ("transfer", "Transferred"), + ("reverse", "Reversed"), + ("cancel", "Cancelled"), + ], + string="Status", + default="draft", + tracking=True, + ) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get("name", "/") == "/": + vals["name"] = ( + self.env["ir.sequence"].next_by_code("budget.transfer") or "/" + ) + return super().create(vals_list) + + def unlink(self): + """Check state draft can delete only.""" + if any(rec.state != "draft" for rec in self): + raise UserError( + _("You are trying to delete a record that is still referenced!") + ) + return super().unlink() + + def action_cancel(self): + self.write({"state": "cancel"}) + + def action_submit(self): + item_ids = self.mapped("transfer_item_ids") + if not item_ids: + raise UserError(_("You need to add a line before submit.")) + for transfer in item_ids: + transfer._check_constraint_transfer() + self.write({"state": "submit"}) + + def action_transfer(self): + self.mapped("transfer_item_ids").transfer() + self._check_budget_control() + self.write({"state": "transfer"}) + + def action_reverse(self): + self.mapped("transfer_item_ids").reverse() + self._check_budget_control() + self.write({"state": "reverse"}) + + def _check_budget_available_analytic(self, budget_controls): + BudgetPeriod = self.env["budget.period"] + for budget_ctrl in budget_controls: + query_data = BudgetPeriod._get_budget_avaiable( + budget_ctrl.analytic_account_id.id, budget_ctrl.template_line_ids + ) + balance = sum(q["amount"] for q in query_data if q["amount"] is not None) + if balance < 0.0: + raise ValidationError( + _("This transfer will result in negative budget balance for %s") + % budget_ctrl.name + ) + return True + + def _check_budget_control(self): + """Ensure no budget control will result in negative balance.""" + transfers = self.mapped("transfer_item_ids") + budget_controls = transfers.mapped("budget_control_from_id") | transfers.mapped( + "budget_control_to_id" + ) + # Control all analytic + self._check_budget_available_analytic(budget_controls) diff --git a/budget_control/models/budget_transfer_item.py b/budget_control/models/budget_transfer_item.py new file mode 100644 index 00000000..d522614c --- /dev/null +++ b/budget_control/models/budget_transfer_item.py @@ -0,0 +1,154 @@ +# Copyright 2020 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError +from odoo.tools import float_compare + + +class BudgetTransferItem(models.Model): + _name = "budget.transfer.item" + _description = "Budget Transfer by Item" + + transfer_id = fields.Many2one( + comodel_name="budget.transfer", + ondelete="cascade", + index=True, + ) + budget_period_id = fields.Many2one( + comodel_name="budget.period", + related="transfer_id.budget_period_id", + ) + budget_control_from_id = fields.Many2one( + comodel_name="budget.control", + string="From", + domain="[('budget_period_id', '=', budget_period_id)]", + required=True, + index=True, + ) + budget_control_to_id = fields.Many2one( + comodel_name="budget.control", + string="To", + domain="[('budget_period_id', '=', budget_period_id)]", + required=True, + index=True, + ) + amount_from_available = fields.Float( + compute="_compute_amount_available", + store="True", + readonly=True, + ) + amount_to_available = fields.Float( + compute="_compute_amount_available", + store="True", + readonly=True, + ) + state_from = fields.Selection( + related="budget_control_from_id.state", + string="State From", + store=True, + ) + state_to = fields.Selection( + related="budget_control_to_id.state", + string="State To", + store=True, + ) + amount = fields.Float( + string="Transfer Amount", + ) + currency_id = fields.Many2one( + comodel_name="res.currency", + default=lambda self: self.env.user.company_id.currency_id, + ) + state = fields.Selection(related="transfer_id.state", store=True) + + def _get_budget_control_transfer(self): + from_budget_ctrl = self.budget_control_from_id + to_budget_ctrl = self.budget_control_to_id + return from_budget_ctrl, to_budget_ctrl + + @api.depends("budget_control_from_id", "budget_control_to_id") + def _compute_amount_available(self): + for transfer in self: + ( + from_budget_ctrl, + to_budget_ctrl, + ) = transfer._get_budget_control_transfer() + transfer.amount_from_available = from_budget_ctrl.amount_balance + transfer.amount_to_available = to_budget_ctrl.amount_balance + + def _check_constraint_transfer(self): + self.ensure_one() + if self.budget_control_from_id == self.budget_control_to_id: + raise UserError( + _("You can not transfer from the same budget control sheet!") + ) + # check amount transfer must be positive + if ( + float_compare( + self.amount, + 0.0, + precision_rounding=self.currency_id.rounding, + ) + != 1 + ): + raise UserError(_("Transfer amount must be positive!")) + # check amount transfer must less than amount available (source budget) + if ( + float_compare( + self.amount, + self.amount_from_available, + precision_rounding=self.currency_id.rounding, + ) + == 1 + ): + raise UserError( + _("Transfer amount can not be exceeded {:,.2f}").format( + self.amount_from_available + ) + ) + + def transfer(self): + for transfer in self: + transfer._check_constraint_transfer() + transfer.budget_control_from_id.released_amount -= transfer.amount + transfer.budget_control_to_id.released_amount += transfer.amount + # Final check + from_amounts = self.mapped("budget_control_from_id.released_amount") + if list(filter(lambda a: a < 0, from_amounts)): + raise ValidationError(_("Negative from amount after transfer!")) + + def reverse(self): + for transfer in self: + transfer.budget_control_from_id.released_amount += transfer.amount + transfer.budget_control_to_id.released_amount -= transfer.amount + + @api.constrains("state_from", "state_to") + def _check_state(self): + """ + Condition to constrain + - Budget Transfer have to state 'draft' or 'submit' + - Budget Control Sheet have to state 'draft' only. + """ + BudgetControl = self.env["budget.control"] + for transfer in self: + is_state_transfer_valid = transfer.transfer_id.state in ["draft", "submit"] + from_budget_ctrl = ( + transfer.state_from != "draft" + and transfer.budget_control_from_id + or BudgetControl + ) + to_budget_ctrl = ( + transfer.state_to != "draft" + and transfer.budget_control_to_id + or BudgetControl + ) + budget_not_draft = from_budget_ctrl + to_budget_ctrl + budget_not_draft = ", ".join(budget_not_draft.mapped("name")) + if is_state_transfer_valid and budget_not_draft: + raise UserError( + _( + "Following budget controls must be in state 'Draft', " + "before transferring.\n{}" + ).format(budget_not_draft) + ) diff --git a/budget_control/models/res_company.py b/budget_control/models/res_company.py new file mode 100644 index 00000000..467a870d --- /dev/null +++ b/budget_control/models/res_company.py @@ -0,0 +1,42 @@ +# Copyright 2020 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + budget_include_tax = fields.Boolean( + string="Budget Included Tax", + help="If checked, all budget moves amount will include tax", + ) + budget_include_tax_method = fields.Selection( + selection=[ + ("all", "All documents & taxes"), + ("specific", "Specific document & taxes"), + ], + default="all", + ) + budget_include_tax_account = fields.Many2many( + comodel_name="account.tax", + relation="company_budget_include_tax_account_rel", + column1="company_id", + column2="tax_id", + ) + budget_include_tax_purchase = fields.Many2many( + comodel_name="account.tax", + relation="company_budget_include_tax_purchase_rel", + column1="company_id", + column2="tax_id", + ) + budget_include_tax_expense = fields.Many2many( + comodel_name="account.tax", + relation="company_budget_include_tax_expense_rel", + column1="company_id", + column2="tax_id", + ) + budget_template_id = fields.Many2one( + comodel_name="budget.template", + string="Budget Template", + ) diff --git a/budget_control/models/res_config_settings.py b/budget_control/models/res_config_settings.py new file mode 100644 index 00000000..f43e6cdc --- /dev/null +++ b/budget_control/models/res_config_settings.py @@ -0,0 +1,50 @@ +# Copyright 2020 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + # Tax Included + budget_include_tax = fields.Boolean( + related="company_id.budget_include_tax", readonly=False + ) + budget_include_tax_method = fields.Selection( + related="company_id.budget_include_tax_method", readonly=False + ) + budget_include_tax_account = fields.Many2many( + related="company_id.budget_include_tax_account", readonly=False + ) + budget_include_tax_purchase = fields.Many2many( + related="company_id.budget_include_tax_purchase", readonly=False + ) + budget_include_tax_expense = fields.Many2many( + related="company_id.budget_include_tax_expense", readonly=False + ) + # -- + budget_template_id = fields.Many2one( + comodel_name="budget.template", + related="company_id.budget_template_id", + readonly=False, + ) + group_required_analytic = fields.Boolean( + string="Required Analytic Account", + implied_group="budget_control.group_required_analytic", + ) + group_budget_date_commit = fields.Boolean( + string="Enable Date Commit", + implied_group="budget_control.group_budget_date_commit", + ) + # Modules + budget_control_account = fields.Boolean( + string="Account", + default=True, + readonly=True, + ) + module_budget_control_purchase_request = fields.Boolean(string="Purchase Request") + module_budget_control_purchase = fields.Boolean(string="Purchase") + module_budget_control_expense = fields.Boolean(string="Expense") + module_budget_control_advance_clearing = fields.Boolean(string="Advance/Clearing") + module_budget_plan = fields.Boolean(string="Budget Plan") diff --git a/budget_control/readme/CONTRIBUTORS.rst b/budget_control/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000..9cf80039 --- /dev/null +++ b/budget_control/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Kitti Upariphutthiphong +* Saran Lim. diff --git a/budget_control/readme/DESCRIPTION.rst b/budget_control/readme/DESCRIPTION.rst new file mode 100644 index 00000000..e1094a19 --- /dev/null +++ b/budget_control/readme/DESCRIPTION.rst @@ -0,0 +1,123 @@ +This module is the main module from a set of budget control modules. +This module alone will allow you to work in full cycle of budget control process. +Other modules, each one are the small enhancement of this module, to fullfill +additional needs. Having said that, following will describe the full cycle of budget +control already provided by this module, + +Budget Control Core Features: +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* **Budget Commitment (base.budget.move)** + + Probably the most crucial part of budget_control. + + * Budget Balance = Budget Allocated - (Budget Actuals - Budget Commitments) + + Actual amount are from `account.move.line` from posted invoice. Commitments can be sales/purchase, + expense, purchase request, etc. Document required to be budget commitment can extend base.budget.move. + For example, the module budget_control_expense will create budget commitment `expense.budget.move` + for approved expense. + Note that, in this budget_control module, there is no extension for budget commitment yet. + +* **Budget Template (budget.template)** + + A Budget Template in the budget control system serves as a framework for controlling the budget, + allowing for the budget to be managed according to the pre-defined template. + The budget template has a relationship with the accounting, + and is used to control spending based on pre-configured accounts. + +* **Budget Period (budget.period)** + + Budget Period is the first thing to do for new budget year, and is used to govern how budget will be + controlled over the defined date range, i.e., + + * Duration of budget year + * Template to control (budget.template) + * Document to do budget checking + * Analytic account in controlled + * Control Level + + Although not mandatory, an organization will most likely use fiscal year as budget period. + In such case, there will be 1 budget period per fiscal year, and multiple budget control sheet (one per analytic). + +* **Budget Control Sheet (budget.control)** + + Each analytic account can have one budget control sheet per budget period. + The budget control is used to allocate budget amount in a simpler way. + In the backend it simply create budget.control.line, nothing too fancy. + Once we have budget allocations, the system is ready to perform budget check. + +* **Budget Checking** + + By calling function -- check_budget(), system will check whether the confirmation + of such document can result in negative budget balance. If so, it throw error message. + In this module, budget check occur during posting of invoice and journal entry. + To check budget also on more documents, do install budget_control_xxx relevant to that document. + +* **Budget Constraint** + + To make the function -- check_budget() more flexible, + additional rules or limitations can be added to the budget checking process. + The system will perform the regular budget check and will also check the additional conditions specified + in the added rules. An example of using budget constraints can be seen from the budget_allocation module. + +* **Budget Reports** + + Currently there are 2 types of report. + + 1. Budget Monitoring: combine all budget related transactions, and show them in Standard Odoo BI view. + 2. Actual Budget Moves: combine all actual commit transactions, and show them in Standard Odoo BI view. + +* **Budget Commitment Move Forward** + + In case budget commitment is being used. Sometime user has committed budget withing this year + but not ready to use it and want to move the commitment amount to next year budget. + Budget Commitment Forward can be use to change the budget move's date to the designated year. + +* **Budget Transfer** + + This module allow transferring allocated budget from one budget control sheet to other + + +Extended Modules: +~~~~~~~~~~~~~~~~~ + +Following are brief explanation of what the extended module will do. + +**Budget Move extension** + +These modules extend base.budget.move for other document budget commitment. + +* budget_control_expense +* budget_control_purchase +* budget_control_purchase_request +* budget_control_sale + +**Budget Allocation** + +This module is the main module for manage allocation (source of fund, analytic tag and analytic account) +until set budget control. and allow create Master Data source of fund, analytic tag dimension. +Users can view source of fund monitoring report + +* budget_allocation + +**Tier Validation** + +Extend base_tier_validation for budget control sheet + +* budget_control_tier_validation + +**Analytic Tag Dimension Enhancements** + +When 1 dimension (analytic account) is not enough, +we can use dimension to create persistent dimension columns + +- analytic_tag_dimension +- analytic_tag_dimension_enhanced + +Following modules ensure that, analytic_tag_dimension will work with all new +budget control objects. These are important for reporting purposes. + +* budget_allocation +* budget_allocation_expense +* budget_allocation_purchase diff --git a/budget_control/readme/USAGE.rst b/budget_control/readme/USAGE.rst new file mode 100644 index 00000000..8090f484 --- /dev/null +++ b/budget_control/readme/USAGE.rst @@ -0,0 +1,55 @@ +Before start using this module, following access right must be set. + - Budget User for Budget Control Sheet, Budget Report + - Budget Manager for Budget Period + +Followings are sample steps to start with, + +1. Create new Budget KPI + + To create budget KPI using in budget template + +2. Create new Budget Template + + - Add new template for controlling Budget following kpi-account + +3. Create new Budget Period + + - Choose Budget template + - Identify date range, i.e., 1 fiscal year + - Plan Date Range, i.e., Quarter, the slot to fill allocation in budget control will split by quarter + - Control Budget = True (if not check = not check budget for this period) + +4. Create Budget Control Sheet + + To create budget control sheet, you can either create manually one by one or by using the helper, + Action > Create Budget Control Sheet + + - Choose Analytic budget_control_purchase_tag_dimension + - Check All Analytic Account, this will list all analytic account in selected groups + - Uncheck Initial Budget By Commitment, this is used only on following year to + init budget allocation if they were committed amount carried over. + - Click "Create Budget Control Sheet", and then view the newly created control sheets. + +5. Allocate amount in Budget Control Sheets + + Each analytic account will have its own sheet. Form Budget Period, click on the + icon "Budget Control Sheets" or by Menu > Budgeting > Budget Control Sheet, to open them. + + - Based on "Plan Date Range" period, Plan table will show all KPI split by Plan Date Range + - Allocate budget amount as appropriate. + - Click Control button, state will change to Controlled. + + Note: Make sure the Plan Date Rang period already has date ranges that covers entire budget period. + Once ready, you can click on "Reset Plan" anytime. + +6. Budget Reports + + After some document transaction (i.e., invoice for actuals), you can view report anytime. + + - On Budget Control sheet, click on Monitoring for see this budget report + - Menu Budgeting > Budget Monitoring, to show budget report in standard Odoo BI view. + +7. Budget Checking + + As we have checked Control Budget = True in third step, checking will occur + every time an invoice is validated. You can test by validate invoice with big amount to exceed. diff --git a/budget_control/report/__init__.py b/budget_control/report/__init__.py new file mode 100644 index 00000000..16216c42 --- /dev/null +++ b/budget_control/report/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import budget_monitor_report diff --git a/budget_control/report/budget_monitor_report.py b/budget_control/report/budget_monitor_report.py new file mode 100644 index 00000000..c63aa625 --- /dev/null +++ b/budget_control/report/budget_monitor_report.py @@ -0,0 +1,194 @@ +# Copyright 2020 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class BudgetMonitorReport(models.Model): + _name = "budget.monitor.report" + _description = "Budget Monitoring Report" + _auto = False + _order = "date desc" + _rec_name = "reference" + + res_id = fields.Reference( + selection=lambda self: [("budget.control.line", "Budget Control Lines")] + + self._get_budget_docline_model(), + string="Resource ID", + ) + kpi_id = fields.Many2one( + comodel_name="budget.kpi", + string="KPI", + ) + source_document = fields.Char() + reference = fields.Char() + analytic_account_id = fields.Many2one( + comodel_name="account.analytic.account", + ) + analytic_plan = fields.Many2one( + comodel_name="account.analytic.plan", + ) + date = fields.Date() + amount = fields.Float() + amount_type = fields.Selection( + selection=lambda self: [("1_budget", "Budget")] + + self._get_budget_amount_type(), + string="Type", + ) + product_id = fields.Many2one( + comodel_name="product.product", + ) + account_id = fields.Many2one( + comodel_name="account.account", + ) + budget_period_id = fields.Many2one( + comodel_name="budget.period", + ) + budget_state = fields.Selection( + [ + ("draft", "Draft"), + ("submit", "Submitted"), + ("done", "Controlled"), + ("cancel", "Cancelled"), + ], + ) + fwd_commit = fields.Boolean() + active = fields.Boolean() + + @property + def _table_query(self): + return """ + select a.*, p.id as budget_period_id + from ({}) a + left outer join date_range d + on a.date between d.date_start and d.date_end + left outer join budget_period p + on a.date between p.bm_date_from and p.bm_date_to + {} + """.format( + self._get_sql(), self._get_where_clause() + ) + + def _get_consumed_sources(self): + return [ + { + "model": ("account.move.line", "Account Move Line"), + "type": ("8_actual", "Actual"), + "budget_move": ("account_budget_move", "move_line_id"), + "source_doc": ("account_move", "move_id"), + } + ] + + def _get_budget_docline_model(self): + """Return list of all res_id models selection""" + return [x["model"] for x in self._get_consumed_sources()] + + def _get_budget_amount_type(self): + """Return list of all amount_type selection""" + return [x["type"] for x in self._get_consumed_sources()] + + def _get_select_amount_types(self): + sql_select = {} + for source in self._get_consumed_sources(): + res_model = source["model"][0] # i.e., account.move.line + amount_type = source["type"][0] # i.e., 8_actual + res_field = source["budget_move"][1] # i.e., move_line_id + sql_select[amount_type] = { + 0: """ + %s000000000 + a.id as id, + '%s,' || a.%s as res_id, + a.kpi_id, + a.analytic_account_id, + a.analytic_plan, + a.date as date, + '%s' as amount_type, + a.credit-a.debit as amount, + a.product_id, + a.account_id, + a.reference as reference, + a.source_document as source_document, + null::char as budget_state, + a.fwd_commit, + 1::boolean as active + """ + % (amount_type[:1], res_model, res_field, amount_type) + } + return sql_select + + def _get_from_amount_types(self): + sql_from = {} + for source in self._get_consumed_sources(): + budget_table = source["budget_move"][0] # i.e., account_budget_move + doc_table = source["source_doc"][0] # i.e., account_move + doc_field = source["source_doc"][1] # i.e., move_id + amount_type = source["type"][0] # i.e., 8_actual + sql_from[ + amount_type + ] = """ + from {} a + left outer join {} b on a.{} = b.id + """.format( + budget_table, + doc_table, + doc_field, + ) + return sql_from + + def _select_budget(self): + return { + 0: """ + 1000000000 + a.id as id, + 'budget.control.line,' || a.id as res_id, + a.kpi_id, + a.analytic_account_id, + b.analytic_plan, + a.date_to as date, -- approx date + '1_budget' as amount_type, + a.amount as amount, + null::integer as product_id, + null::integer as account_id, + b.name as reference, + null::char as source_document, + b.state as budget_state, + 0::boolean as fwd_commit, + a.active as active + """ + } + + def _from_budget(self): + return """ + from budget_control_line a + join budget_control b on a.budget_control_id = b.id + and b.active = true + """ + + def _select_statement(self, amount_type): + return self._get_select_amount_types()[amount_type] + + def _from_statement(self, amount_type): + return self._get_from_amount_types()[amount_type] + + def _where_actual(self): + return "" + + def _get_sql(self): + select_budget_query = self._select_budget() + key_select_budget_list = sorted(select_budget_query.keys()) + select_budget = ", ".join( + select_budget_query[x] for x in key_select_budget_list + ) + select_actual_query = self._select_statement("8_actual") + key_select_actual_list = sorted(select_budget_query.keys()) + select_actual = ", ".join( + select_actual_query[x] for x in key_select_actual_list + ) + return "(select {} {}) union (select {} {} {})".format( + select_budget, + self._from_budget(), + select_actual, + self._from_statement("8_actual"), + self._where_actual(), + ) + + def _get_where_clause(self): + return "where d.type_id = p.plan_date_range_type_id" diff --git a/budget_control/report/budget_monitor_report_view.xml b/budget_control/report/budget_monitor_report_view.xml new file mode 100644 index 00000000..eeef1f5b --- /dev/null +++ b/budget_control/report/budget_monitor_report_view.xml @@ -0,0 +1,131 @@ + + + + budget.monitor.report.tree + budget.monitor.report + + + + + + + + + + + + + + + budget.monitor.report.pivot + budget.monitor.report + + + + + + + + + + budget.monitor.report.graph + budget.monitor.report + + + + + + + + + budget.monitor.report.search + budget.monitor.report + + + + + + + + + + + + + + + + + + + + + + + + Budget Monitoring + budget.monitor.report + pivot,tree,graph + { + 'group_by':[], + 'group_by_no_leaf':1, + 'search_default_budget_state_done': True + } + + + + diff --git a/budget_control/report/budget_move_views.xml b/budget_control/report/budget_move_views.xml new file mode 100644 index 00000000..90b5bdc7 --- /dev/null +++ b/budget_control/report/budget_move_views.xml @@ -0,0 +1,95 @@ + + + + account.budget.move.tree + account.budget.move + + + + + + + + + + + + + + + + + account_budget_move.pivot + account.budget.move + + + + + + + + + + account.budget.move.search + account.budget.move + + + + + + + + + + + + + + + + + + + + + Actual Budget Commitment + account.budget.move + pivot,tree + + + + diff --git a/budget_control/security/budget_control_rules.xml b/budget_control/security/budget_control_rules.xml new file mode 100644 index 00000000..746fddea --- /dev/null +++ b/budget_control/security/budget_control_rules.xml @@ -0,0 +1,31 @@ + + + + + Budget Control Rule For Budget Users + + [('assignee_id', '=', user.id)] + + + + + + + + Budget Control Rule For Budget Manager + + [(1, '=', 1)] + + + + + + + diff --git a/budget_control/security/budget_control_security_groups.xml b/budget_control/security/budget_control_security_groups.xml new file mode 100644 index 00000000..b293b9e3 --- /dev/null +++ b/budget_control/security/budget_control_security_groups.xml @@ -0,0 +1,34 @@ + + + + + Budget Control + Helps you handle your budgeting needs. + 10 + + + Budget User + + + + Budget Manager + + + + + + Required Analytic Account + + + + Enable Date Commit + + + diff --git a/budget_control/security/ir.model.access.csv b/budget_control/security/ir.model.access.csv new file mode 100644 index 00000000..79fcdd63 --- /dev/null +++ b/budget_control/security/ir.model.access.csv @@ -0,0 +1,35 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +access_budget_period_manager,access_budget_period_manager,model_budget_period,budget_control.group_budget_control_manager,1,1,1,1 +access_budget_period_user,access_budget_period_user,model_budget_period,base.group_user,1,0,0,0 +access_budget_control,access_budget_control,model_budget_control,budget_control.group_budget_control_user,1,1,1,1 +access_budget_control_line,access_budget_control_line,model_budget_control_line,budget_control.group_budget_control_user,1,1,1,1 +access_generate_budget_control,access_generate_budget_control,model_generate_budget_control,budget_control.group_budget_control_manager,1,1,1,1 +access_budget_template_manager,access_budget_template_manager,model_budget_template,budget_control.group_budget_control_manager,1,1,1,1 +access_budget_template_accounting,access_budget_template_accounting,model_budget_template,account.group_account_user,1,1,1,1 +access_budget_template_user,access_budget_template_user,model_budget_template,,1,0,0,0 +access_budget_template_line_manager,access_budget_template_line_manager,model_budget_template_line,budget_control.group_budget_control_manager,1,1,1,1 +access_budget_template_line_accounting,access_budget_template_line_accounting,model_budget_template_line,account.group_account_user,1,1,1,1 +access_budget_template_line_user,access_budget_template_line_user,model_budget_template_line,,1,0,0,0 +access_budget_kpi_manager,access_budget_kpi_manager,model_budget_kpi,budget_control.group_budget_control_manager,1,1,1,1 +access_budget_kpi,access_budget_kpi,model_budget_kpi,budget_control.group_budget_control_user,1,1,0,0 +access_account_budget_move_user,access_account_budget_move_user,model_account_budget_move,,1,1,1,1 +access_budget_monitor_report,access_budget_monitor_report,model_budget_monitor_report,base.group_user,1,1,1,1 +access_budget_state_confirmation,access_budget_state_confirmation,model_budget_state_confirmation,budget_control.group_budget_control_manager,1,1,1,1 +access_budget_move_adjustment,access_budget_move_adjustment,model_budget_move_adjustment,budget_control.group_budget_control_manager,1,1,1,1 +access_budget_move_adjustment_item,access_budget_move_adjustment_item,model_budget_move_adjustment_item,budget_control.group_budget_control_manager,1,1,1,1 +access_budget_transfer_user,access_budget_transfer_user,model_budget_transfer,,1,1,1,1 +access_budget_transfer_item_user,access_budget_transfer_item_user,model_budget_transfer_item,,1,1,1,1 +access_budget_commit_forward,access_budget_commit_forward,model_budget_commit_forward,budget_control.group_budget_control_manager,1,1,1,1 +access_budget_commit_forward_line,access_budget_commit_forward_line,model_budget_commit_forward_line,budget_control.group_budget_control_manager,1,1,1,1 +access_budget_commit_forward_info,access_budget_commit_forward_info,model_budget_commit_forward_info,budget_control.group_budget_control_manager,1,1,1,1 +access_budget_commit_forward_info_line,access_budget_commit_forward_info_line,model_budget_commit_forward_info_line,budget_control.group_budget_control_manager,1,1,1,1 +access_budget_balance_forward,access_budget_balance_forward,model_budget_balance_forward,budget_control.group_budget_control_manager,1,1,1,1 +access_budget_balance_forward_line,access_budget_balance_forward_line,model_budget_balance_forward_line,budget_control.group_budget_control_manager,1,1,1,1 +access_budget_balance_forward_info,access_budget_balance_forward_info,model_budget_balance_forward_info,budget_control.group_budget_control_manager,1,1,1,1 +access_budget_balance_forward_info_line,access_budget_balance_forward_info_line,model_budget_balance_forward_info_line,budget_control.group_budget_control_manager,1,1,1,1 +access_analytic_budget_info,access_analytic_budget_info,model_analytic_budget_info,base.group_user,1,0,0,0 +access_analytic_budget_edit,access_analytic_budget_edit,model_analytic_budget_edit,budget_control.group_budget_control_manager,1,1,1,1 +access_account_analytic_account_budget,access_account_analytic_account_budget,model_account_analytic_account,budget_control.group_budget_control_manager,1,1,1,1 +access_budget_constraint,access_budget_constraint,model_budget_constraint,,1,0,0,0 +access_budget_constraint_manager,access_budget_constraint_manager,model_budget_constraint,budget_control.group_budget_control_manager,1,1,1,1 +analytic.access_account_analytic_account,access_account_analytic_account,analytic.model_account_analytic_account,analytic.group_analytic_accounting,1,0,0,0 diff --git a/budget_control/static/description/icon.png b/budget_control/static/description/icon.png new file mode 100644 index 00000000..4ecfaa3e Binary files /dev/null and b/budget_control/static/description/icon.png differ diff --git a/budget_control/static/description/index.html b/budget_control/static/description/index.html new file mode 100644 index 00000000..dbb42604 --- /dev/null +++ b/budget_control/static/description/index.html @@ -0,0 +1,602 @@ + + + + + +Budget Control + + + +
+

Budget Control

+ + +

Alpha License: AGPL-3 ecosoft-odoo/budgeting

+

This module is the main module from a set of budget control modules. +This module alone will allow you to work in full cycle of budget control process. +Other modules, each one are the small enhancement of this module, to fullfill +additional needs. Having said that, following will describe the full cycle of budget +control already provided by this module,

+
+

Budget Control Core Features:

+
    +
  • Budget Commitment (base.budget.move)

    +

    Probably the most crucial part of budget_control.

    +
      +
    • Budget Balance = Budget Allocated - (Budget Actuals - Budget Commitments)
    • +
    +

    Actual amount are from account.move.line from posted invoice. Commitments can be sales/purchase, +expense, purchase request, etc. Document required to be budget commitment can extend base.budget.move. +For example, the module budget_control_expense will create budget commitment expense.budget.move +for approved expense. +Note that, in this budget_control module, there is no extension for budget commitment yet.

    +
  • +
  • Budget Template (budget.template)

    +

    A Budget Template in the budget control system serves as a framework for controlling the budget, +allowing for the budget to be managed according to the pre-defined template. +The budget template has a relationship with the accounting, +and is used to control spending based on pre-configured accounts.

    +
  • +
  • Budget Period (budget.period)

    +

    Budget Period is the first thing to do for new budget year, and is used to govern how budget will be +controlled over the defined date range, i.e.,

    +
      +
    • Duration of budget year
    • +
    • Template to control (budget.template)
    • +
    • Document to do budget checking
    • +
    • Analytic account in controlled
    • +
    • Control Level
    • +
    +

    Although not mandatory, an organization will most likely use fiscal year as budget period. +In such case, there will be 1 budget period per fiscal year, and multiple budget control sheet (one per analytic).

    +
  • +
  • Budget Control Sheet (budget.control)

    +

    Each analytic account can have one budget control sheet per budget period. +The budget control is used to allocate budget amount in a simpler way. +In the backend it simply create budget.control.line, nothing too fancy. +Once we have budget allocations, the system is ready to perform budget check.

    +
  • +
  • Budget Checking

    +

    By calling function – check_budget(), system will check whether the confirmation +of such document can result in negative budget balance. If so, it throw error message. +In this module, budget check occur during posting of invoice and journal entry. +To check budget also on more documents, do install budget_control_xxx relevant to that document.

    +
  • +
  • Budget Constraint

    +

    To make the function – check_budget() more flexible, +additional rules or limitations can be added to the budget checking process. +The system will perform the regular budget check and will also check the additional conditions specified +in the added rules. An example of using budget constraints can be seen from the budget_allocation module.

    +
  • +
  • Budget Reports

    +

    Currently there are 2 types of report.

    +
      +
    1. Budget Monitoring: combine all budget related transactions, and show them in Standard Odoo BI view.
    2. +
    3. Actual Budget Moves: combine all actual commit transactions, and show them in Standard Odoo BI view.
    4. +
    +
  • +
  • Budget Commitment Move Forward

    +

    In case budget commitment is being used. Sometime user has committed budget withing this year +but not ready to use it and want to move the commitment amount to next year budget. +Budget Commitment Forward can be use to change the budget move’s date to the designated year.

    +
  • +
  • Budget Transfer

    +

    This module allow transferring allocated budget from one budget control sheet to other

    +
  • +
+
+
+

Extended Modules:

+

Following are brief explanation of what the extended module will do.

+

Budget Move extension

+

These modules extend base.budget.move for other document budget commitment.

+
    +
  • budget_control_expense
  • +
  • budget_control_purchase
  • +
  • budget_control_purchase_request
  • +
  • budget_control_sale
  • +
+

Budget Allocation

+

This module is the main module for manage allocation (source of fund, analytic tag and analytic account) +until set budget control. and allow create Master Data source of fund, analytic tag dimension. +Users can view source of fund monitoring report

+
    +
  • budget_allocation
  • +
+

Tier Validation

+

Extend base_tier_validation for budget control sheet

+
    +
  • budget_control_tier_validation
  • +
+

Analytic Tag Dimension Enhancements

+

When 1 dimension (analytic account) is not enough, +we can use dimension to create persistent dimension columns

+
    +
  • analytic_tag_dimension
  • +
  • analytic_tag_dimension_enhanced
  • +
+

Following modules ensure that, analytic_tag_dimension will work with all new +budget control objects. These are important for reporting purposes.

+
    +
  • budget_allocation
  • +
  • budget_allocation_expense
  • +
  • budget_allocation_purchase
  • +
+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

Usage

+
+
Before start using this module, following access right must be set.
+
    +
  • Budget User for Budget Control Sheet, Budget Report
  • +
  • Budget Manager for Budget Period
  • +
+
+
+

Followings are sample steps to start with,

+
    +
  1. Create new Budget KPI

    +

    To create budget KPI using in budget template

    +
  2. +
  3. Create new Budget Template

    +
      +
    • Add new template for controlling Budget following kpi-account
    • +
    +
  4. +
  5. Create new Budget Period

    +
    +
      +
    • Choose Budget template
    • +
    • Identify date range, i.e., 1 fiscal year
    • +
    • Plan Date Range, i.e., Quarter, the slot to fill allocation in budget control will split by quarter
    • +
    • Control Budget = True (if not check = not check budget for this period)
    • +
    +
    +
  6. +
  7. Create Budget Control Sheet

    +

    To create budget control sheet, you can either create manually one by one or by using the helper, +Action > Create Budget Control Sheet

    +
    +
      +
    • Choose Analytic budget_control_purchase_tag_dimension
    • +
    • Check All Analytic Account, this will list all analytic account in selected groups
    • +
    • Uncheck Initial Budget By Commitment, this is used only on following year to +init budget allocation if they were committed amount carried over.
    • +
    • Click “Create Budget Control Sheet”, and then view the newly created control sheets.
    • +
    +
    +
  8. +
  9. Allocate amount in Budget Control Sheets

    +

    Each analytic account will have its own sheet. Form Budget Period, click on the +icon “Budget Control Sheets” or by Menu > Budgeting > Budget Control Sheet, to open them.

    +
    +
      +
    • Based on “Plan Date Range” period, Plan table will show all KPI split by Plan Date Range
    • +
    • Allocate budget amount as appropriate.
    • +
    • Click Control button, state will change to Controlled.
    • +
    +
    +

    Note: Make sure the Plan Date Rang period already has date ranges that covers entire budget period. +Once ready, you can click on “Reset Plan” anytime.

    +
  10. +
  11. Budget Reports

    +

    After some document transaction (i.e., invoice for actuals), you can view report anytime.

    +
    +
      +
    • On Budget Control sheet, click on Monitoring for see this budget report
    • +
    • Menu Budgeting > Budget Monitoring, to show budget report in standard Odoo BI view.
    • +
    +
    +
  12. +
  13. Budget Checking

    +

    As we have checked Control Budget = True in third step, checking will occur +every time an invoice is validated. You can test by validate invoice with big amount to exceed.

    +
  14. +
+
+
+

Bug Tracker

+

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

+

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

+
+ +
+
+

Authors

+
    +
  • Ecosoft
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

Current maintainers:

+

kittiu ru3ix-bbb

+

This module is part of the ecosoft-odoo/budgeting project on GitHub.

+

You are welcome to contribute.

+
+
+ + diff --git a/budget_control/static/src/xml/budget_popover.xml b/budget_control/static/src/xml/budget_popover.xml new file mode 100644 index 00000000..9dfbd9e1 --- /dev/null +++ b/budget_control/static/src/xml/budget_popover.xml @@ -0,0 +1,38 @@ + + + + +
+ +
+ + + + + + + + + + + + + + + +
+ Planned + + +
+ Used + + - +
+ Available + + = +
+
+
+
diff --git a/budget_control/tests/__init__.py b/budget_control/tests/__init__.py new file mode 100644 index 00000000..e1d2a567 --- /dev/null +++ b/budget_control/tests/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import common +from . import test_budget_control diff --git a/budget_control/tests/common.py b/budget_control/tests/common.py new file mode 100644 index 00000000..079bd0e6 --- /dev/null +++ b/budget_control/tests/common.py @@ -0,0 +1,185 @@ +# Copyright 2020 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from datetime import datetime + +from dateutil.rrule import MONTHLY + +from odoo import Command +from odoo.tests.common import TransactionCase + + +class BudgetControlCommon(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env.company.budget_include_tax = False # Not Tax Included + cls.year = datetime.now().year + cls.RangeType = cls.env["date.range.type"] + cls.Analytic = cls.env["account.analytic.account"] + cls.AnalyticPlan = cls.env["account.analytic.plan"] + cls.Account = cls.env["account.account"] + cls.BudgetControl = cls.env["budget.control"] + cls.BudgetTemplate = cls.env["budget.template"] + cls.BudgetKPI = cls.env["budget.kpi"] + cls.Product = cls.env["product.product"] + cls.Partner = cls.env["res.partner"] + cls.Move = cls.env["account.move"] + + # Create vendor + cls.vendor = cls.Partner.create({"name": "Sample Vendor"}) + # Create quarterly date range for current year + cls.date_range_type = cls.RangeType.create({"name": "TestQuarter"}) + cls._create_date_range_quarter(cls) + # Setup some required entity + cls.account_kpi1 = cls.Account.create( + {"name": "KPI1", "code": "KPI1", "account_type": "expense"} + ) + cls.account_kpi2 = cls.Account.create( + {"name": "KPI2", "code": "KPI2", "account_type": "expense"} + ) + cls.account_kpi3 = cls.Account.create( + {"name": "KPI3", "code": "KPI3", "account_type": "expense"} + ) + # Create an extra account, but not in control + cls.account_kpiX = cls.Account.create( + {"name": "KPIX", "code": "KPIX", "account_type": "expense"} + ) + # Create an extra account, for advance + cls.account_kpiAV = cls.Account.create( + { + "name": "KPIAV", + "code": "KPIAV", + "account_type": "asset_current", + "reconcile": True, + } + ) + cls.kpi1 = cls.BudgetKPI.create({"name": "kpi 1"}) + cls.kpi2 = cls.BudgetKPI.create({"name": "kpi 2"}) + cls.kpi3 = cls.BudgetKPI.create({"name": "kpi 3"}) + + cls.product1 = cls.Product.create( + { + "name": "Product 1", + "property_account_expense_id": cls.account_kpi1.id, + } + ) + cls.product2 = cls.Product.create( + { + "name": "Product 2", + "property_account_expense_id": cls.account_kpi2.id, + } + ) + cls.template = cls.BudgetTemplate.create({"name": "Test KPI"}) + + # Create budget kpis + cls._create_budget_template_kpi(cls) + # Create budget.period for current year + cls.budget_period = cls._create_budget_period_fy( + cls, cls.template.id, cls.date_range_type.id + ) + + cls.aa_plan1 = cls.AnalyticPlan.create({"name": "Plan1"}) + # Create budget.control for CostCenter1, + # by selected budget_id and date range (by quarter) + cls.costcenter1 = cls.Analytic.create( + {"name": "CostCenter1", "plan_id": cls.aa_plan1.id} + ) + cls.costcenterX = cls.Analytic.create( + {"name": "CostCenterX", "plan_id": cls.aa_plan1.id} + ) + + def _create_date_range_quarter(self): + Generator = self.env["date.range.generator"] + generator = Generator.create( + { + "date_start": "%s-01-01" % self.year, + "name_prefix": "%s/Test/Q-" % self.year, + "type_id": self.date_range_type.id, + "duration_count": 3, + "unit_of_time": str(MONTHLY), + "count": 4, + } + ) + generator.action_apply() + + def _create_budget_template_kpi(self): + # create template kpis + self.template_line1 = self.env["budget.template.line"].create( + { + "template_id": self.template.id, + "kpi_id": self.kpi1.id, + "account_ids": [(4, self.account_kpi1.id)], + } + ) + self.template_line2 = self.env["budget.template.line"].create( + { + "template_id": self.template.id, + "kpi_id": self.kpi2.id, + "account_ids": [(4, self.account_kpi2.id)], + } + ) + self.template_line3 = self.env["budget.template.line"].create( + { + "template_id": self.template.id, + "kpi_id": self.kpi3.id, + "account_ids": [(4, self.account_kpi3.id)], + } + ) + + def _create_budget_period_fy(self, template_id, date_range_type_id): + BudgetPeriod = self.env["budget.period"] + budget_period = BudgetPeriod.create( + { + "name": "Budget for FY%s" % self.year, + "template_id": template_id, + "bm_date_from": "%s-01-01" % self.year, + "bm_date_to": "%s-12-31" % self.year, + "plan_date_range_type_id": date_range_type_id, + "control_level": "analytic_kpi", + } + ) + return budget_period + + def _create_invoice( + self, inv_type, vendor, invoice_date, analytic_distribution, invoice_lines + ): + invoice = self.Move.create( + { + "move_type": inv_type, + "partner_id": vendor.id, + "invoice_date": invoice_date, + "invoice_line_ids": [ + Command.create( + { + "quantity": 1, + "account_id": il.get("account"), + "price_unit": il.get("price_unit"), + "analytic_distribution": analytic_distribution, + }, + ) + for il in invoice_lines + ], + } + ) + return invoice + + def _create_simple_bill(self, analytic_distribution, account, amount): + invoice = self.Move.create( + { + "move_type": "in_invoice", + "partner_id": self.vendor.id, + "invoice_date": datetime.today(), + "invoice_line_ids": [ + Command.create( + { + "quantity": 1, + "account_id": account.id, + "price_unit": amount, + "analytic_distribution": analytic_distribution, + }, + ) + ], + } + ) + return invoice diff --git a/budget_control/tests/test_budget_control.py b/budget_control/tests/test_budget_control.py new file mode 100644 index 00000000..2af735b7 --- /dev/null +++ b/budget_control/tests/test_budget_control.py @@ -0,0 +1,301 @@ +# Copyright 2020 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from datetime import datetime + +from freezegun import freeze_time + +from odoo.exceptions import UserError +from odoo.tests import tagged + +from .common import BudgetControlCommon + + +@tagged("post_install", "-at_install") +class TestBudgetControl(BudgetControlCommon): + @classmethod + @freeze_time("2001-02-01") + def setUpClass(cls): + super().setUpClass() + # Create sample ready to use Budget Control + cls.budget_control = cls.BudgetControl.create( + { + "name": "CostCenter1/%s" % cls.year, + "template_id": cls.budget_period.template_id.id, + "budget_period_id": cls.budget_period.id, + "analytic_account_id": cls.costcenter1.id, + "plan_date_range_type_id": cls.date_range_type.id, + "template_line_ids": [ + cls.template_line1.id, + cls.template_line2.id, + cls.template_line3.id, + ], + } + ) + # Test item created for 3 kpi x 4 quarters = 12 budget items + cls.budget_control.prepare_budget_control_matrix() + assert len(cls.budget_control.line_ids) == 12 + # Assign budget.control amount: KPI1 = 100x4=400, KPI2=800, KPI3=1,200 + cls.budget_control.line_ids.filtered(lambda x: x.kpi_id == cls.kpi1).write( + {"amount": 100} + ) + cls.budget_control.line_ids.filtered(lambda x: x.kpi_id == cls.kpi2).write( + {"amount": 200} + ) + cls.budget_control.line_ids.filtered(lambda x: x.kpi_id == cls.kpi3).write( + {"amount": 300} + ) + + @freeze_time("2001-02-01") + def test_01_no_budget_control_check(self): + """Invoice with analytic that has no budget_control candidate, + - If use KPI not in control -> lock + - If control_all_analytic_accounts is checked -> Lock + - If analytic in control_analytic_account_ids -> Lock + - Else -> No Lock + """ + self.budget_period.control_budget = True + # KPI not in control -> lock + analytic_distribution = {self.costcenter1.id: 100} + bill1 = self._create_simple_bill(analytic_distribution, self.account_kpiX, 100) + with self.assertRaises(UserError): + bill1.action_post() + bill1.button_draft() + # Valid KPI + control_all_analytic_accounts is checked + self.budget_period.control_all_analytic_accounts = True + bill2 = self._create_simple_bill( + analytic_distribution, self.account_kpi1, 100000 + ) + with self.assertRaises(UserError): + bill2.action_post() + bill2.button_draft() + # Valid KPI + analytic in control_analytic_account_ids + self.budget_period.control_analytic_account_ids = self.costcenter1 + bill3 = self._create_simple_bill( + analytic_distribution, self.account_kpi1, 100000 + ) + with self.assertRaises(UserError): + bill3.action_post() + bill3.button_draft() + # Else, even valid KPI + self.budget_period.control_all_analytic_accounts = False + self.budget_period.control_analytic_account_ids = False + bill4 = self._create_simple_bill( + analytic_distribution, self.account_kpi1, 100000 + ) + bill4.action_post() + self.assertTrue(bill4.budget_move_ids) + + @freeze_time("2001-02-01") + def test_02_budget_control_not_confirmed(self): + """ + - If budget_control for an analytic exists but not confirmed, + invoice raise warning + - If budget_control for is not set allocated amount, + invoice raise warning + """ + self.budget_period.control_budget = True + analytic_distribution = {self.costcenter1.id: 100} + bill1 = self._create_simple_bill(analytic_distribution, self.account_kpi1, 400) + # Now, budget_control is not yet set to Done, raise error when post invoice + with self.assertRaises(UserError): + bill1.action_post() + self.assertEqual(bill1.state, "draft") + self.assertFalse(bill1.budget_move_ids) + # As budget_control has not set allocated_amount, raise error when set Done + with self.assertRaises(UserError): + self.budget_control.action_done() + # Allocate and Done + self.budget_control.allocated_amount = 2400 + self.budget_control.action_done() + self.assertEqual(self.budget_control.released_amount, 2400) + self.assertEqual(self.budget_control.state, "done") + # Post again + bill1.action_post() + self.assertEqual(bill1.state, "posted") + + @freeze_time("2001-02-01") + def test_03_control_level_analytic_kpi(self): + """ + Budget Period set control_level to "analytic_kpi", check at KPI level + If amount exceed 400, lock budget + """ + self.budget_period.control_budget = True + self.budget_period.control_level = "analytic_kpi" + analytic_distribution = {self.costcenter1.id: 100} + # Budget Controlled + self.budget_control.allocated_amount = 2400 + self.budget_control.action_done() + # Test with amount = 401 + bill1 = self._create_simple_bill(analytic_distribution, self.account_kpi1, 401) + with self.assertRaises(UserError): + bill1.action_post() + + @freeze_time("2001-02-01") + def test_04_control_level_analytic(self): + """ + Budget Period set control_level to "analytic", check at Analytic level + If amount exceed 400, not lock budget and still has balance after that + """ + self.budget_period.control_budget = True + self.budget_period.control_level = "analytic" + analytic_distribution = {self.costcenter1.id: 100} + # Budget Controlled + self.budget_control.allocated_amount = 2400 + self.budget_control.action_done() + # Test with amount = 2000 + bill1 = self._create_simple_bill(analytic_distribution, self.account_kpi1, 2000) + bill1.action_post() + self.assertEqual(bill1.state, "posted") + self.assertTrue(self.budget_control.amount_balance) + + @freeze_time("2001-02-01") + def test_05_no_account_budget_check(self): + """If budget.period is not set to check budget, no budget check in all cases""" + # No budget check + self.budget_period.control_budget = False + analytic_distribution = {self.costcenter1.id: 100} + # Budget Controlled + self.budget_control.allocated_amount = 2400 + self.budget_control.action_done() + # Create big amount invoice transaction > 2400 + bill1 = self._create_simple_bill( + analytic_distribution, self.account_kpi1, 100000 + ) + bill1.action_post() + + @freeze_time("2001-02-01") + def test_06_refund_no_budget_check(self): + """For refund, always not checking""" + # First, make budget actual to exceed budget first + self.budget_period.control_budget = False # No budget check first + self.budget_control.allocated_amount = 2400 + analytic_distribution = {self.costcenter1.id: 100} + self.budget_control.action_done() + self.assertEqual(self.budget_control.amount_balance, 2400) + bill1 = self._create_simple_bill( + analytic_distribution, self.account_kpi1, 100000 + ) + bill1.action_post() + # Update budget info + self.budget_control._compute_budget_info() + self.assertEqual(self.budget_control.amount_balance, -97600) + # Check budget, for in_refund, force no budget check + self.budget_period.control_budget = True + self.budget_control.action_draft() + invoice = self._create_invoice( + "in_refund", + self.vendor, + datetime.today(), + analytic_distribution, + [{"account": self.account_kpi1.id, "price_unit": 100}], + ) + invoice.action_post() + # Update budget info + self.budget_control._compute_budget_info() + self.assertEqual(self.budget_control.amount_balance, -97500) + + @freeze_time("2001-02-01") + def test_07_auto_date_commit(self): + """ + - Budget move's date_commit should follow that in _budget_date_commit_fields + - If date_commit is not inline with analytic date range, adjust it automatically + - Use the auto date_commit to create budget move + - On cancel of document (unlink budget moves), date_commit is set to False + """ + self.budget_period.control_budget = False + # First setup self.costcenterX valid date range and auto adjust + self.costcenterX.bm_date_from = "2001-01-01" + self.costcenterX.bm_date_to = "2001-12-31" + analytic_distribution = {self.costcenterX.id: 100} + self.costcenterX.auto_adjust_date_commit = True + # date_commit should follow that in _budget_date_commit_fields + bill1 = self._create_simple_bill(analytic_distribution, self.account_kpiX, 10) + self.assertIn( + "move_id.date", + self.env["account.move.line"]._budget_date_commit_fields, + ) + bill1.invoice_date = "2001-05-05" + bill1.date = "2001-05-05" + # account in bill1 is not control + with self.assertRaises(UserError): + bill1.action_post() + # change account to control budget + bill1.invoice_line_ids.account_id = self.account_kpi1.id + bill1.action_post() + self.assertEqual(bill1.invoice_date, bill1.budget_move_ids.mapped("date")[0]) + # If date is out of range, adjust automatically, to analytic date range + bill2 = self._create_simple_bill(analytic_distribution, self.account_kpi1, 10) + self.assertIn( + "move_id.date", + self.env["account.move.line"]._budget_date_commit_fields, + ) + bill2.invoice_date = "2002-05-05" + bill2.date = "2002-05-05" + bill2.action_post() + self.assertEqual( + self.costcenterX.bm_date_to, + bill2.budget_move_ids.mapped("date")[0], + ) + # On cancel of document, date_commit = False + bill2.button_draft() + self.assertFalse(bill2.invoice_line_ids.mapped("date_commit")[0]) + + def test_08_manual_date_commit_check(self): + """ + - If date_commit is not inline with analytic date range, show error + """ + self.budget_period.control_budget = False + analytic_distribution = {self.costcenterX.id: 100} + # First setup self.costcenterX valid date range and auto adjust + self.costcenterX.bm_date_from = "2001-01-01" + self.costcenterX.bm_date_to = "2001-12-31" + self.costcenterX.auto_adjust_date_commit = True + # Manual Date Commit + bill1 = self._create_simple_bill(analytic_distribution, self.account_kpiX, 10) + bill1.invoice_date = "2001-05-05" + bill1.date = "2001-05-05" + # Use manual date_commit = "2002-10-10" which is not in range. + bill1.invoice_line_ids[0].date_commit = "2002-10-10" + with self.assertRaises(UserError): + bill1.action_post() + + @freeze_time("2001-02-01") + def test_09_force_no_budget_check(self): + """ + By passing context["force_no_budget_check"] = True, no check in all case + """ + self.budget_period.control_budget = True + analytic_distribution = {self.costcenter1.id: 100} + # Budget Controlled + self.budget_control.allocated_amount = 2400 + self.budget_control.action_done() + # Test with bit amount + bill1 = self._create_simple_bill( + analytic_distribution, self.account_kpi1, 100000 + ) + bill1.with_context(force_no_budget_check=True).action_post() + + def test_10_recompute_budget_move_date_commit(self): + """ + - Date budget commit should be the same after recompute + """ + self.budget_period.control_budget = False + analytic_distribution = {self.costcenterX.id: 100} + self.costcenterX.auto_adjust_date_commit = True + # Ma + bill1 = self._create_simple_bill(analytic_distribution, self.account_kpiX, 10) + bill1.invoice_date = "2002-10-10" + bill1.date = "2002-10-10" + # Use manual date_commit = "2002-10-10" which is not in range. + bill1.invoice_line_ids[0].date_commit = "2002-10-10" + bill1.action_post() + self.assertEqual( + bill1.budget_move_ids[0].date, + bill1.invoice_line_ids[0].date_commit, + ) + bill1.recompute_budget_move() + self.assertEqual( + bill1.budget_move_ids[0].date, + bill1.invoice_line_ids[0].date_commit, + ) diff --git a/budget_control/views/account_budget_move.xml b/budget_control/views/account_budget_move.xml new file mode 100644 index 00000000..faac4194 --- /dev/null +++ b/budget_control/views/account_budget_move.xml @@ -0,0 +1,44 @@ + + + + + view.account.budget.move.form + account.budget.move + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
diff --git a/budget_control/views/account_journal_view.xml b/budget_control/views/account_journal_view.xml new file mode 100644 index 00000000..e934a958 --- /dev/null +++ b/budget_control/views/account_journal_view.xml @@ -0,0 +1,13 @@ + + + + account.journal.form + account.journal + + + + + + + + diff --git a/budget_control/views/account_move_views.xml b/budget_control/views/account_move_views.xml new file mode 100644 index 00000000..910b0370 --- /dev/null +++ b/budget_control/views/account_move_views.xml @@ -0,0 +1,140 @@ + + + + + account.move.line.form + account.move.line + + + + + + + + + account.move.line.tree + account.move.line + + + + + + + + + + account.move.form + account.move + + + + + + + + + + + + + + + show + + + + + + + + + +
+
+ + + + + + + + + + + + + + +
+
+
+
+
diff --git a/budget_control/views/analytic_account_views.xml b/budget_control/views/analytic_account_views.xml new file mode 100644 index 00000000..e4f2116c --- /dev/null +++ b/budget_control/views/analytic_account_views.xml @@ -0,0 +1,147 @@ + + + + account.analytic.account.search + account.analytic.account + + + + + + + + + + account.analytic.account.list + account.analytic.account + + + + + + + + + + + + + + analytic.analytic.account.form + account.analytic.account + + + + + + + + + + + + + + + + view.budget.analytic.list + account.analytic.account + + + + + + + + + + + + + + + + + + + + + + Analytic Accounts + ir.actions.act_window + account.analytic.account + + + {'search_default_active':1} + tree,kanban,form + +

+ Add a new analytic account +

+
+
+ + + +
diff --git a/budget_control/views/budget_balance_forward_view.xml b/budget_control/views/budget_balance_forward_view.xml new file mode 100644 index 00000000..cd5dc950 --- /dev/null +++ b/budget_control/views/budget_balance_forward_view.xml @@ -0,0 +1,177 @@ + + + + view.budget.balance.forward.tree + budget.balance.forward + + + + + + + + + + + view.budget.balance.forward.line.tree + budget.balance.forward.line + + + + + + + + + + + + + + + + view.budget.balance.forward.form + budget.balance.forward + +
+
+
+ +
+
+ + + + + + + + + + + + + +
+

+ This operation will find amount balance (planned - consumed) in current analtyic, + and set as Initial Available in Carry Forward Analytic (and Accumulate Analytic) +

+

+

    +
  1. Click Review Budget Balance button, to find current balance of all analtyics.
  2. +
  3. Review method to forward, if required, click Create Missing Analytic. +
      +
    • Blank: Analytic is open end, use the same analytic
    • +
    • New: To Analytic Account is next year analytic (need to create if missing)
    • +
    • Extend: Use same analtyic but extend end date to next year
    • +
    +
  4. +
  5. Fill in amount, both forward and accumulate (optional), and click Forward Budget Balance
  6. +
+

+
+
+
+
+
+ + +
+
+
+
+ + Forward Budget Balance + budget.balance.forward + + tree,form + + +
diff --git a/budget_control/views/budget_commit_forward_view.xml b/budget_control/views/budget_commit_forward_view.xml new file mode 100644 index 00000000..b938c260 --- /dev/null +++ b/budget_control/views/budget_commit_forward_view.xml @@ -0,0 +1,197 @@ + + + + view.budget.commit.forward.tree + budget.commit.forward + + + + + + + + + + view.budget.commit.forward.line.tree + budget.commit.forward.line + + + + + + + + + + + + + + + + + view.budget.commit.forward.line.form + budget.commit.forward.line + +
+ + + + + + + + + + + + + + +
+
+
+ + view.budget.commit.forward.form + budget.commit.forward + +
+
+
+ +
+
+ + + + + + + + + +