From eeeae61bfab5260668d2e03c1620ecf7beecff23 Mon Sep 17 00:00:00 2001 From: Saran440 Date: Tue, 17 Oct 2023 13:37:54 +0700 Subject: [PATCH 01/24] [15.0][ADD] budget_control --- budget_control/README.rst | 266 ++++++++ budget_control/__init__.py | 5 + budget_control/__manifest__.py | 57 ++ budget_control/data/sequence_data.xml | 17 + budget_control/demo/budget_template_demo.xml | 37 ++ budget_control/hooks.py | 20 + budget_control/models/__init__.py | 20 + budget_control/models/account_budget_move.py | 50 ++ budget_control/models/account_journal.py | 12 + budget_control/models/account_move.py | 94 +++ budget_control/models/account_move_line.py | 66 ++ budget_control/models/analytic_account.py | 248 +++++++ budget_control/models/base_budget_move.py | 575 ++++++++++++++++ .../models/budget_balance_forward.py | 379 +++++++++++ .../models/budget_commit_forward.py | 384 +++++++++++ budget_control/models/budget_constraint.py | 25 + budget_control/models/budget_control.py | 594 +++++++++++++++++ budget_control/models/budget_kpi.py | 11 + .../models/budget_move_adjustment.py | 196 ++++++ budget_control/models/budget_period.py | 495 ++++++++++++++ budget_control/models/budget_template.py | 50 ++ budget_control/models/budget_transfer.py | 105 +++ budget_control/models/budget_transfer_item.py | 154 +++++ budget_control/models/res_company.py | 42 ++ budget_control/models/res_config_settings.py | 50 ++ budget_control/readme/CONTRIBUTORS.rst | 2 + budget_control/readme/DESCRIPTION.rst | 130 ++++ budget_control/readme/USAGE.rst | 59 ++ budget_control/report/__init__.py | 3 + .../report/budget_monitor_report.py | 194 ++++++ .../report/budget_monitor_report_view.xml | 131 ++++ budget_control/report/budget_move_views.xml | 96 +++ .../security/budget_control_rules.xml | 31 + .../budget_control_security_groups.xml | 34 + budget_control/security/ir.model.access.csv | 35 + budget_control/static/description/icon.png | Bin 0 -> 25915 bytes budget_control/static/description/index.html | 611 ++++++++++++++++++ .../static/src/xml/budget_popover.xml | 37 ++ budget_control/tests/__init__.py | 4 + budget_control/tests/common.py | 165 +++++ budget_control/tests/test_budget_control.py | 275 ++++++++ budget_control/views/account_journal_view.xml | 13 + budget_control/views/account_move_views.xml | 161 +++++ .../views/analytic_account_views.xml | 155 +++++ .../views/budget_balance_forward_view.xml | 177 +++++ .../views/budget_commit_forward_view.xml | 189 ++++++ .../views/budget_constraint_view.xml | 83 +++ budget_control/views/budget_control_view.xml | 368 +++++++++++ budget_control/views/budget_kpi_view.xml | 48 ++ budget_control/views/budget_menuitem.xml | 58 ++ .../views/budget_move_adjustment_view.xml | 250 +++++++ budget_control/views/budget_period_view.xml | 95 +++ budget_control/views/budget_template_view.xml | 74 +++ .../views/budget_transfer_item_view.xml | 80 +++ budget_control/views/budget_transfer_view.xml | 142 ++++ .../views/res_config_settings_views.xml | 275 ++++++++ budget_control/wizards/__init__.py | 8 + .../wizards/analytic_budget_edit.py | 23 + .../wizards/analytic_budget_edit_view.xml | 31 + .../wizards/analytic_budget_info.py | 39 ++ .../wizards/analytic_budget_info_view.xml | 55 ++ .../wizards/budget_balance_forward_info.py | 75 +++ .../budget_balance_forward_info_view.xml | 61 ++ .../wizards/budget_commit_forward_info.py | 76 +++ .../budget_commit_forward_info_view.xml | 61 ++ .../wizards/confirm_state_budget.py | 33 + .../wizards/confirm_state_budget_view.xml | 28 + .../wizards/generate_budget_control.py | 215 ++++++ .../wizards/generate_budget_control_view.xml | 96 +++ 69 files changed, 8728 insertions(+) create mode 100644 budget_control/README.rst create mode 100644 budget_control/__init__.py create mode 100644 budget_control/__manifest__.py create mode 100644 budget_control/data/sequence_data.xml create mode 100644 budget_control/demo/budget_template_demo.xml create mode 100644 budget_control/hooks.py create mode 100644 budget_control/models/__init__.py create mode 100644 budget_control/models/account_budget_move.py create mode 100644 budget_control/models/account_journal.py create mode 100644 budget_control/models/account_move.py create mode 100644 budget_control/models/account_move_line.py create mode 100644 budget_control/models/analytic_account.py create mode 100644 budget_control/models/base_budget_move.py create mode 100644 budget_control/models/budget_balance_forward.py create mode 100644 budget_control/models/budget_commit_forward.py create mode 100644 budget_control/models/budget_constraint.py create mode 100644 budget_control/models/budget_control.py create mode 100644 budget_control/models/budget_kpi.py create mode 100644 budget_control/models/budget_move_adjustment.py create mode 100644 budget_control/models/budget_period.py create mode 100644 budget_control/models/budget_template.py create mode 100644 budget_control/models/budget_transfer.py create mode 100644 budget_control/models/budget_transfer_item.py create mode 100644 budget_control/models/res_company.py create mode 100644 budget_control/models/res_config_settings.py create mode 100644 budget_control/readme/CONTRIBUTORS.rst create mode 100644 budget_control/readme/DESCRIPTION.rst create mode 100644 budget_control/readme/USAGE.rst create mode 100644 budget_control/report/__init__.py create mode 100644 budget_control/report/budget_monitor_report.py create mode 100644 budget_control/report/budget_monitor_report_view.xml create mode 100644 budget_control/report/budget_move_views.xml create mode 100644 budget_control/security/budget_control_rules.xml create mode 100644 budget_control/security/budget_control_security_groups.xml create mode 100644 budget_control/security/ir.model.access.csv create mode 100644 budget_control/static/description/icon.png create mode 100644 budget_control/static/description/index.html create mode 100644 budget_control/static/src/xml/budget_popover.xml create mode 100644 budget_control/tests/__init__.py create mode 100644 budget_control/tests/common.py create mode 100644 budget_control/tests/test_budget_control.py create mode 100644 budget_control/views/account_journal_view.xml create mode 100644 budget_control/views/account_move_views.xml create mode 100644 budget_control/views/analytic_account_views.xml create mode 100644 budget_control/views/budget_balance_forward_view.xml create mode 100644 budget_control/views/budget_commit_forward_view.xml create mode 100644 budget_control/views/budget_constraint_view.xml create mode 100644 budget_control/views/budget_control_view.xml create mode 100644 budget_control/views/budget_kpi_view.xml create mode 100644 budget_control/views/budget_menuitem.xml create mode 100644 budget_control/views/budget_move_adjustment_view.xml create mode 100644 budget_control/views/budget_period_view.xml create mode 100644 budget_control/views/budget_template_view.xml create mode 100644 budget_control/views/budget_transfer_item_view.xml create mode 100644 budget_control/views/budget_transfer_view.xml create mode 100644 budget_control/views/res_config_settings_views.xml create mode 100644 budget_control/wizards/__init__.py create mode 100644 budget_control/wizards/analytic_budget_edit.py create mode 100644 budget_control/wizards/analytic_budget_edit_view.xml create mode 100644 budget_control/wizards/analytic_budget_info.py create mode 100644 budget_control/wizards/analytic_budget_info_view.xml create mode 100644 budget_control/wizards/budget_balance_forward_info.py create mode 100644 budget_control/wizards/budget_balance_forward_info_view.xml create mode 100644 budget_control/wizards/budget_commit_forward_info.py create mode 100644 budget_control/wizards/budget_commit_forward_info_view.xml create mode 100644 budget_control/wizards/confirm_state_budget.py create mode 100644 budget_control/wizards/confirm_state_budget_view.xml create mode 100644 budget_control/wizards/generate_budget_control.py create mode 100644 budget_control/wizards/generate_budget_control_view.xml diff --git a/budget_control/README.rst b/budget_control/README.rst new file mode 100644 index 00000000..4265cd45 --- /dev/null +++ b/budget_control/README.rst @@ -0,0 +1,266 @@ +============== +Budget Control +============== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:170b7aa450e2ccdfa27c0d5840cf49a8511a46198988caa073e23f03a6689384 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/15.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 KPI (budget.kpi)** + + Budget KPI is used to measure the efficiency of planning compared to actual usage. + It is linked to Account Codes, and one Budget KPI can be associated with more than one account code. + +* **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 budget kpi and 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_advance_clearing +* budget_control_contract +* budget_control_expense +* budget_control_purchase +* budget_control_purchase_request + +**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 +* budget_allocation_advance_clearing +* budget_allocation_contract +* budget_allocation_expense +* budget_allocation_purchase +* budget_allocation_purchase_request + +**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. + +.. 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 create by using the helper, + Action > Create Budget Control Sheet + + - Choose Analytic Group + - Check All Analytic Accounts, 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 "Generate 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" or by Menu > Budgeting > Budget Control Sheet, to open them. + + - Within the "Plan Date Range" period, the Plan table displays all KPIs split by Plan Date Range + - If you need to edit the plan, click the "Reset Options" tab, then select the KPIs you want to plan + - Click the "Soft Reset" button to generate KPIs. The amounts in the plan table will not disappear. + - Click the "Hard Reset" button to generate KPIs. The amounts in the plan table will disappear. + - Allocate budget amount as appropriate. + - Click Submit > Control, 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 "Soft Reset" or "Hard Reset" 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 + +Current maintainer: + +|maintainer-kittiu| + +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..a2cf2123 --- /dev/null +++ b/budget_control/__manifest__.py @@ -0,0 +1,57 @@ +# 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": "15.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/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_qweb": [ + "budget_control/static/src/xml/budget_popover.xml", + ], + }, + "installable": True, + "maintainers": ["kittiu"], + "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..756389dc --- /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 + def create(self, vals): + """The default value of "Not affect budget" depends on journal. + except in the case of a manaully created journal entry. + """ + not_affect_budget = vals.get("not_affect_budget", "None") + journal_id = vals.get("journal_id") + if not_affect_budget == "None" and journal_id: + journal = self.env["account.journal"].browse(journal_id) + vals["not_affect_budget"] = journal.not_affect_budget + return super().create(vals) + + 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() + self.flush() + 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..3318c7fd --- /dev/null +++ b/budget_control/models/account_move_line.py @@ -0,0 +1,66 @@ +# 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): + self.ensure_one() + if self.move_id.move_type == "entry": + budget_vals["amount_currency"] = 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 + budget_vals["amount_currency"] = ( + sign * self.price_unit * self.quantity * discount + ) + budget_vals["tax_ids"] = self.tax_ids.ids + # Document specific vals + budget_vals.update( + { + "move_line_id": self.id, + "analytic_tag_ids": [(6, 0, self.analytic_tag_ids.ids)], + } + ) + return super()._init_docline_budget_vals(budget_vals) + + 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"] diff --git a/budget_control/models/analytic_account.py b/budget_control/models/analytic_account.py new file mode 100644 index 00000000..ba5259e4 --- /dev/null +++ b/budget_control/models/analytic_account.py @@ -0,0 +1,248 @@ +# 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"] + 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), + ] + ) + return {"budget_period_id": period_id.id} + + def _auto_create_next_analytic(self, next_date_range): + self.ensure_one() + next_analytic = self.copy() + 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): + self.ensure_one() + if self.auto_adjust_date_commit: + if self.bm_date_from and self.bm_date_from > docline.date_commit: + docline.date_commit = self.bm_date_from + elif self.bm_date_to and self.bm_date_to < docline.date_commit: + docline.date_commit = self.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..29d021de --- /dev/null +++ b/budget_control/models/base_budget_move.py @@ -0,0 +1,575 @@ +# 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", + related="template_line_id.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_group = fields.Many2one( + comodel_name="account.analytic.group", + auto_join=True, + index=True, + readonly=True, + ) + analytic_tag_ids = fields.Many2many( + comodel_name="account.analytic.tag", + string="Analytic Tags", + ) + 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'", + ) + + 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_account_id" + # 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.Float( + 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_account_id = fields.Many2one( + comodel_name="account.analytic.account", + string="Carry Forward Analytic", + copy=False, + readonly=False, + index=True, + 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!")) + + @api.onchange("fwd_analytic_account_id") + def _onchange_fwd_analytic_account_id(self): + self.fwd_date_commit = self.fwd_analytic_account_id.bm_date_from + + @api.depends(lambda self: [self._budget_analytic_field]) + def _compute_auto_adjust_date_commit(self): + for docline in self: + docline.auto_adjust_date_commit = docline[ + self._budget_analytic_field + ].auto_adjust_date_commit + + @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: + debit = sum(rec.budget_move_ids.mapped("debit")) + credit = sum(rec.budget_move_ids.mapped("credit")) + rec.amount_commit = debit - credit + 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 = rec[self._budget_analytic_field] + if not analytic: + 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 + ) + analytic = analytic.with_context(budget_period_ids=[budget_period.id]) + rec.json_budget_popover = dumps( + { + "title": _("Budget Figure"), + "icon": "fa-info-circle", + "popoverTemplate": "budget_control.budgetPopOver", + "analytic": analytic.display_name, + "budget": FloatConverter.value_to_html( + analytic.amount_budget, {"decimal_precision": "Product Price"} + ), + "consumed": FloatConverter.value_to_html( + analytic.amount_consumed, {"decimal_precision": "Product Price"} + ), + "balance": FloatConverter.value_to_html( + analytic.amount_balance, {"decimal_precision": "Product Price"} + ), + } + ) + + 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[self._budget_analytic_field] + # 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, reverse=False): + self.ensure_one() + company = self.env.user.company_id + account = self.account_id + # Check params analytic_account_id, if not it should be self analytic + analytic_account = budget_vals.get("analytic_account_id", False) + if not analytic_account: + analytic_account = self[self._budget_analytic_field] + 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 + 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 + ) + # 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 + today = fields.Date.context_today(self) + res = { + "product_id": self.product_id.id, + "account_id": account.id, + "analytic_account_id": analytic_account.id, + "analytic_group": analytic_account.group_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 + 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_account_id or not docline.fwd_date_commit: + return + if ( + docline[self._budget_analytic_field] == docline.fwd_analytic_account_id + and docline.date_commit == docline.fwd_date_commit + ): # no forward to same date + # docline.fwd_analytic_account_id = False + # docline.fwd_date_commit = False + 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 type entry + if ( + required_analytic + and not self[self._budget_analytic_field] + and not ( + self._name == "account.move.line" and self.move_id.move_type == "entry" + ) + and not self._context.get("bypass_required_analytic") + ): + 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: + # Set amount_currency + budget_vals = self._init_docline_budget_vals(vals) + # 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 + 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, 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_move = self.env[self._budget_model()].create(budget_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): + """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 + analytic = docline[self._budget_analytic_field] + if analytic: + if not docline.date_commit: + raise UserError(_("No budget commitment date")) + 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) 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..2e854aff --- /dev/null +++ b/budget_control/models/budget_commit_forward.py @@ -0,0 +1,384 @@ +# 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", + ) + 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!"), + ] + total_commitment = fields.Monetary( + compute="_compute_total_commitment", + ) + + @api.depends("forward_line_ids") + def _compute_total_commitment(self): + for rec in self: + rec.total_commitment = sum(rec.forward_line_ids.mapped("amount_commit")) + + 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_domain(self): + """For module extension""" + self.ensure_one() + domain = [ + ("amount_commit", ">", 0.0), + ("date_commit", "<", self.to_date_commit), + ("fwd_date_commit", "!=", self.to_date_commit), + ] + return domain + + def _get_commit_docline(self, res_model): + """For module extension""" + return [] + + 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 = [] + for doc in docs: + analytic_account = ( + doc.fwd_analytic_account_id or doc[doc._budget_analytic_field] + ) + method_type = False + if ( + analytic_account.bm_date_to + and analytic_account.bm_date_to < self.to_date_commit + ): + method_type = "new" + value_dict.append( + { + "forward_id": self.id, + "analytic_account_id": analytic_account.id, + "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, + "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 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() + for rec in self: + for line in rec.forward_line_ids: + line.document_id.write( + { + "fwd_analytic_account_id": reverse + and line.analytic_account_id + or line.to_analytic_account_id, + "fwd_date_commit": reverse + and line.date_commit + or rec.to_date_commit, + } + ) + 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 + Analytic = 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 = Analytic.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 = Analytic.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" + + 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, + ) + 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, + readonly=True, + ) + bm_date_to = fields.Date( + related="analytic_account_id.bm_date_to", + readonly=True, + ) + 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("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 + ) 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..afc164a0 --- /dev/null +++ b/budget_control/models/budget_control.py @@ -0,0 +1,594 @@ +# 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_tag_ids = fields.Many2many( + comodel_name="account.analytic.tag", string="Analytic Tags" + ) + analytic_group = fields.Many2one( + comodel_name="account.analytic.group", + string="Analytic Group", + related="analytic_account_id.group_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""" + self.flush() + 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 {} will result in {:,.2f}").format( + line.name, 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 + + @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 {:,.2f} {}" + ).format(rec.released_amount, 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 {:,.2f} {}" + ).format(rec.amount_initial, 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" + ) + analytic_tag_ids = fields.Many2many( + comodel_name="account.analytic.tag", string="Analytic Tags" + ) + 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..722718e5 --- /dev/null +++ b/budget_control/models/budget_move_adjustment.py @@ -0,0 +1,196 @@ +# 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 + def create(self, vals): + """Generate a new name using the 'budget.move.adjustment' sequence""" + if vals.get("name", "/") == "/": + vals["name"] = ( + self.env["ir.sequence"].next_by_code("budget.move.adjustment") or "/" + ) + return super().create(vals) + + 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" + _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, + ) + analytic_tag_ids = fields.Many2many( + comodel_name="account.analytic.tag", + string="Analytic Tags", + ) + 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): + 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, + "analytic_tag_ids": [(6, 0, self.analytic_tag_ids.ids)], + } + ) + return super()._init_docline_budget_vals(budget_vals) + + 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..e8a4ef8b --- /dev/null +++ b/budget_control/models/budget_period.py @@ -0,0 +1,495 @@ +# 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() + # Check budget by group analytic. For case many budget periods in one document. + for aa in doclines[doclines._budget_analytic_field]: + 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 + 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 + # 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} + ) + + @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.flush() + 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) + # 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( + _("{0}, will result in {1}").format(analytic_name, 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..4217e8a8 --- /dev/null +++ b/budget_control/models/budget_transfer.py @@ -0,0 +1,105 @@ +# 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 + def create(self, vals): + if vals.get("name", "/") == "/": + vals["name"] = ( + self.env["ir.sequence"].next_by_code("budget.transfer") or "/" + ) + return super().create(vals) + + 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..55da95ec --- /dev/null +++ b/budget_control/readme/DESCRIPTION.rst @@ -0,0 +1,130 @@ +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 KPI (budget.kpi)** + + Budget KPI is used to measure the efficiency of planning compared to actual usage. + It is linked to Account Codes, and one Budget KPI can be associated with more than one account code. + +* **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 budget kpi and 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_advance_clearing +* budget_control_contract +* budget_control_expense +* budget_control_purchase +* budget_control_purchase_request + +**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 +* budget_allocation_advance_clearing +* budget_allocation_contract +* budget_allocation_expense +* budget_allocation_purchase +* budget_allocation_purchase_request + +**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. diff --git a/budget_control/readme/USAGE.rst b/budget_control/readme/USAGE.rst new file mode 100644 index 00000000..58757c80 --- /dev/null +++ b/budget_control/readme/USAGE.rst @@ -0,0 +1,59 @@ +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 create by using the helper, + Action > Create Budget Control Sheet + + - Choose Analytic Group + - Check All Analytic Accounts, 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 "Generate 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" or by Menu > Budgeting > Budget Control Sheet, to open them. + + - Within the "Plan Date Range" period, the Plan table displays all KPIs split by Plan Date Range + - If you need to edit the plan, click the "Reset Options" tab, then select the KPIs you want to plan + - Click the "Soft Reset" button to generate KPIs. The amounts in the plan table will not disappear. + - Click the "Hard Reset" button to generate KPIs. The amounts in the plan table will disappear. + - Allocate budget amount as appropriate. + - Click Submit > Control, 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 "Soft Reset" or "Hard Reset" 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..b6dd4230 --- /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_group = fields.Many2one( + comodel_name="account.analytic.group", + ) + 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_group, + 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_group, + 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..8b79481b --- /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..0c18af79 --- /dev/null +++ b/budget_control/report/budget_move_views.xml @@ -0,0 +1,96 @@ + + + + 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 0000000000000000000000000000000000000000..4ecfaa3ecc0a9eb98bd092d077e4ce99bbaef4ae GIT binary patch literal 25915 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 DZU~Gi literal 0 HcmV?d00001 diff --git a/budget_control/static/description/index.html b/budget_control/static/description/index.html new file mode 100644 index 00000000..18d004ff --- /dev/null +++ b/budget_control/static/description/index.html @@ -0,0 +1,611 @@ + + + + + + +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 KPI (budget.kpi)

    +

    Budget KPI is used to measure the efficiency of planning compared to actual usage. +It is linked to Account Codes, and one Budget KPI can be associated with more than one account code.

    +
  • +
  • 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 budget kpi and 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_advance_clearing
  • +
  • budget_control_contract
  • +
  • budget_control_expense
  • +
  • budget_control_purchase
  • +
  • budget_control_purchase_request
  • +
+

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
  • +
  • budget_allocation_advance_clearing
  • +
  • budget_allocation_contract
  • +
  • budget_allocation_expense
  • +
  • budget_allocation_purchase
  • +
  • budget_allocation_purchase_request
  • +
+

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.

+
+

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 create by using the helper, +Action > Create Budget Control Sheet

    +
    +
      +
    • Choose Analytic Group
    • +
    • Check All Analytic Accounts, 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 “Generate 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” or by Menu > Budgeting > Budget Control Sheet, to open them.

    +
    +
      +
    • Within the “Plan Date Range” period, the Plan table displays all KPIs split by Plan Date Range
    • +
    • If you need to edit the plan, click the “Reset Options” tab, then select the KPIs you want to plan
    • +
    • Click the “Soft Reset” button to generate KPIs. The amounts in the plan table will not disappear.
    • +
    • Click the “Hard Reset” button to generate KPIs. The amounts in the plan table will disappear.
    • +
    • Allocate budget amount as appropriate.
    • +
    • Click Submit > Control, 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 “Soft Reset” or “Hard Reset” 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 maintainer:

+

kittiu

+

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..1b9fdadf --- /dev/null +++ b/budget_control/static/src/xml/budget_popover.xml @@ -0,0 +1,37 @@ + + +
+

+ +

+ + + + + + + + + + + + + + + + +
+ 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..6c4fb3a0 --- /dev/null +++ b/budget_control/tests/common.py @@ -0,0 +1,165 @@ +# 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.tests.common import Form, 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 + RangeType = cls.env["date.range.type"] + cls.Analytic = cls.env["account.analytic.account"] + cls.BudgetControl = cls.env["budget.control"] + cls.BudgetKPI = cls.env["budget.kpi"] + Partner = cls.env["res.partner"] + # Vendor + cls.vendor = Partner.create({"name": "Sample Vendor"}) + # Create quarterly date range for current year + cls.date_range_type = RangeType.create({"name": "TestQuarter"}) + cls._create_date_range_quarter(cls) + # Setup some required entity + Account = cls.env["account.account"] + type_exp = cls.env.ref("account.data_account_type_expenses").id + type_adv = cls.env.ref("account.data_account_type_current_assets").id + cls.account_kpi1 = Account.create( + {"name": "KPI1", "code": "KPI1", "user_type_id": type_exp} + ) + cls.account_kpi2 = Account.create( + {"name": "KPI2", "code": "KPI2", "user_type_id": type_exp} + ) + cls.account_kpi3 = Account.create( + {"name": "KPI3", "code": "KPI3", "user_type_id": type_exp} + ) + # Create an extra account, but not in control + cls.account_kpiX = Account.create( + {"name": "KPIX", "code": "KPIX", "user_type_id": type_exp} + ) + # Create an extra account, for advance + cls.account_kpiAV = Account.create( + { + "name": "KPIAV", + "code": "KPIAV", + "user_type_id": type_adv, + "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"}) + Product = cls.env["product.product"] + cls.product1 = Product.create( + { + "name": "Product 1", + "property_account_expense_id": cls.account_kpi1.id, + } + ) + cls.product2 = Product.create( + { + "name": "Product 2", + "property_account_expense_id": cls.account_kpi2.id, + } + ) + cls.template = cls.env["budget.template"].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 + ) + # Create budget.control for CostCenter1, + # by selected budget_id and date range (by quarter) + cls.costcenter1 = cls.Analytic.create({"name": "CostCenter1"}) + cls.costcenterX = cls.Analytic.create({"name": "CostCenterX"}) + + 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, invoice_lines): + Invoice = self.env["account.move"] + with Form( + Invoice.with_context(default_move_type=inv_type), + view="account.view_move_form", + ) as inv: + inv.partner_id = vendor + inv.invoice_date = invoice_date + for il in invoice_lines: + with inv.invoice_line_ids.new() as line: + line.quantity = 1 + line.account_id = il.get("account") + line.price_unit = il.get("price_unit") + line.analytic_account_id = analytic + invoice = inv.save() + return invoice + + def _create_simple_bill(self, analytic, account, amount): + Invoice = self.env["account.move"] + view_id = "account.view_move_form" + with Form( + Invoice.with_context(default_move_type="in_invoice"), view=view_id + ) as inv: + inv.partner_id = self.vendor + inv.invoice_date = datetime.today() + with inv.invoice_line_ids.new() as line: + line.quantity = 1 + line.account_id = account + line.price_unit = amount + line.analytic_account_id = analytic + invoice = inv.save() + 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..67c2f708 --- /dev/null +++ b/budget_control/tests/test_budget_control.py @@ -0,0 +1,275 @@ +# 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 + bill1 = self._create_simple_bill(self.costcenter1, 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(self.costcenter1, 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(self.costcenter1, 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(self.costcenter1, 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 + bill1 = self._create_simple_bill(self.costcenter1, 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" + # Budget Controlled + self.budget_control.allocated_amount = 2400 + self.budget_control.action_done() + # Test with amount = 401 + bill1 = self._create_simple_bill(self.costcenter1, 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" + # Budget Controlled + self.budget_control.allocated_amount = 2400 + self.budget_control.action_done() + # Test with amount = 2000 + bill1 = self._create_simple_bill(self.costcenter1, 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 + # Budget Controlled + self.budget_control.allocated_amount = 2400 + self.budget_control.action_done() + # Create big amount invoice transaction > 2400 + bill1 = self._create_simple_bill(self.costcenter1, 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 + self.budget_control.action_done() + self.assertEqual(self.budget_control.amount_balance, 2400) + bill1 = self._create_simple_bill(self.costcenter1, self.account_kpi1, 100000) + bill1.action_post() + 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(), + self.costcenter1, + [{"account": self.account_kpi1, "price_unit": 100}], + ) + invoice.action_post() + 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" + self.costcenterX.auto_adjust_date_commit = True + # date_commit should follow that in _budget_date_commit_fields + bill1 = self._create_simple_bill(self.costcenterX, 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(self.costcenterX, 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 + # 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(self.costcenterX, 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 + # Budget Controlled + self.budget_control.allocated_amount = 2400 + self.budget_control.action_done() + # Test with bit amount + bill1 = self._create_simple_bill(self.costcenter1, 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 + self.costcenterX.auto_adjust_date_commit = True + # Ma + bill1 = self._create_simple_bill(self.costcenterX, 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_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..728108ff --- /dev/null +++ b/budget_control/views/account_move_views.xml @@ -0,0 +1,161 @@ + + + + + account.move.line.form + account.move.line + + + + + + + + + account.move.line.tree + account.move.line + + + + + + + + + account.move.line.tree.grouped + 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..3ffe6ad4 --- /dev/null +++ b/budget_control/views/analytic_account_views.xml @@ -0,0 +1,155 @@ + + + + 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..e699975e --- /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..26bf7c3f --- /dev/null +++ b/budget_control/views/budget_commit_forward_view.xml @@ -0,0 +1,189 @@ + + + + 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 + +
+
+
+ +
+
+ + + + + + + + + + + +
+

+ This operation will move budget commitment of all documents below to the new commitment date. + The amount will be set in Initial Commitment in Analytic. +

+

+

    +
  1. Select document type to Forward Budget Commitment.
  2. +
  3. Click Review Budget Commitment, to pull all commited documents.
  4. +
  5. Review documement's 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
    • +
    +
  6. +
  7. When ready, click Forward Budget Commitment to move commitment.
  8. +
+

+
+
+
+
+
+ + +
+
+
+
+ + Forward Budget Commitment + budget.commit.forward + + tree,form + + +
diff --git a/budget_control/views/budget_constraint_view.xml b/budget_control/views/budget_constraint_view.xml new file mode 100644 index 00000000..6ecfc248 --- /dev/null +++ b/budget_control/views/budget_constraint_view.xml @@ -0,0 +1,83 @@ + + + + view.budget.constraint.filter + budget.constraint + + + + + + + + + + budget.constraint.view.tree + budget.constraint + + + + + + + + + + budget.constraint.view.form + budget.constraint + +
+
+ + +
+
+ + + + + + + + + + +
+
+ + +
+ + + + + Budget Constraint + + budget.constraint + tree,form + + + diff --git a/budget_control/views/budget_control_view.xml b/budget_control/views/budget_control_view.xml new file mode 100644 index 00000000..6b0ba836 --- /dev/null +++ b/budget_control/views/budget_control_view.xml @@ -0,0 +1,368 @@ + + + + + budget.control.line.tree.view.readonly + budget.control.line + + + + + + + + + + + + Budget Control Lines + budget.control.line + tree,form + [('budget_control_id', '=', active_id)] + + + + + + budget.control.view.tree + budget.control + + + + + + + + + + + + + + + + + + + + + + view.budget.control.filter + budget.control + + + + + + + + + + + + + + + + + + + budget.control.view.form + budget.control + +
+
+
+ + +
+ +
+ +
+
+
+ + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + +
+
+
+ + + +
+
+
+
+ + Budget Control Sheet + + budget.control + {} + tree,form + + + Budget Control Sheet + + budget.control + {'search_default_current_period': 1} + tree,form + + + + + Update Status + + + code + + list + +action = records.action_confirm_state() + + + +
diff --git a/budget_control/views/budget_kpi_view.xml b/budget_control/views/budget_kpi_view.xml new file mode 100644 index 00000000..5850fb01 --- /dev/null +++ b/budget_control/views/budget_kpi_view.xml @@ -0,0 +1,48 @@ + + + + budget.kpi.view.tree + budget.kpi + + + + + + + + budget.kpi.view.form + budget.kpi + +
+ +
+
+
+

+ +

+
+ + + + +
+
+
+
+ + Budget KPI + + budget.kpi + tree,form + + + +
diff --git a/budget_control/views/budget_menuitem.xml b/budget_control/views/budget_menuitem.xml new file mode 100644 index 00000000..d06af1cd --- /dev/null +++ b/budget_control/views/budget_menuitem.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + diff --git a/budget_control/views/budget_move_adjustment_view.xml b/budget_control/views/budget_move_adjustment_view.xml new file mode 100644 index 00000000..f4d9b5b2 --- /dev/null +++ b/budget_control/views/budget_move_adjustment_view.xml @@ -0,0 +1,250 @@ + + + + view.budget.move.adjustment.search + budget.move.adjustment + + + + + + + + + + + + + + + view.budget.move.adjustment.tree + budget.move.adjustment + + + + + + + + + + view.budget.move.adjustment.form + budget.move.adjustment + +
+
+
+ +
+
+
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + +
+ +
+

+ Create actual budget commitment (account.budget.move) + in order to adjust overall budget balance for any selected analytic account. + Typically, this process is useful when start using budgeting system in middle + of the year, and that not all the initial figure are stable. +

+

+ To use this, add adjustment lines with amount you want to adjust. + Then click Adjust button, and system will create account.budget.move. +

+

+ There are 2 adjust types, +

    +
  1. Consume: Create acutal budget commitment as debit amount, to lower the budget balance.
  2. +
  3. Release: Create acutal budget commitment as credit amont, to increase the budget balance.
  4. +
+

+
+
+
+
+
+ + +
+ +
+
+ + + Budget Moves Adjustment + + budget.move.adjustment + tree,form + + +
diff --git a/budget_control/views/budget_period_view.xml b/budget_control/views/budget_period_view.xml new file mode 100644 index 00000000..175971ca --- /dev/null +++ b/budget_control/views/budget_period_view.xml @@ -0,0 +1,95 @@ + + + + budget.period.view.tree + budget.period + + + + + + + + + + budget.period.view.form + budget.period + +
+ +
+
+
+
+
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + Budget Period + + budget.period + tree,form + + +
diff --git a/budget_control/views/budget_template_view.xml b/budget_control/views/budget_template_view.xml new file mode 100644 index 00000000..48f48b4b --- /dev/null +++ b/budget_control/views/budget_template_view.xml @@ -0,0 +1,74 @@ + + + + budget.template.view.tree + budget.template + + + + + + + + budget.template.view.form + budget.template + +
+ +
+
+
+

+ +

+
+ + + + + + + + + + + + + + + +
+

+ This operation will involve creating a template for budget checking, which will have the following principles of creation. +

+

+

    +
  1. Account: Multiple items can be selected in each line. Used for budget commitment (PR/PO/CT/AV/EX/Actual). The template is checked to see if there is enough budget.
  2. +
  3. KPI: Groups of accounts can be created from the Configuration > Budget KPI menu.
  4. +
+

+
+
+
+
+
+
+
+ + Budget Template + + budget.template + tree,form + + + +
diff --git a/budget_control/views/budget_transfer_item_view.xml b/budget_control/views/budget_transfer_item_view.xml new file mode 100644 index 00000000..49b76141 --- /dev/null +++ b/budget_control/views/budget_transfer_item_view.xml @@ -0,0 +1,80 @@ + + + + view.budget.transfer.item.tree + budget.transfer.item + + + + + + + + + + + + + + + + + + view.budget.transfer.item.form + budget.transfer.item + +
+ + + + + + + + + + + + + + + + + +
+
+
+ + + + view.budget.transfer.item.ref.tree + budget.transfer.item + + primary + + + + + + + + + view.budget.transfer.item.ref.form + budget.transfer.item + + primary + + + + + + + + +
diff --git a/budget_control/views/budget_transfer_view.xml b/budget_control/views/budget_transfer_view.xml new file mode 100644 index 00000000..2a98172b --- /dev/null +++ b/budget_control/views/budget_transfer_view.xml @@ -0,0 +1,142 @@ + + + + view.budget.transfer.search + budget.transfer + + + + + + + + + + + + + + + + + + view.budget.transfer.tree + budget.transfer + + + + + + + + + + view.budget.transfer.form + budget.transfer + +
+
+
+ +
+
+
+

+ +

+
+ + + + + + + +
+
+ + +
+
+
+
+ + Budget Transfer + + budget.transfer + tree,form + + +
diff --git a/budget_control/views/res_config_settings_views.xml b/budget_control/views/res_config_settings_views.xml new file mode 100644 index 00000000..aefa57f6 --- /dev/null +++ b/budget_control/views/res_config_settings_views.xml @@ -0,0 +1,275 @@ + + + + res.config.settings.view.form.budget + res.config.settings + + + + +
+

Budget Control Documents

+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+

Budget Control Options

+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+

Budget Period

+
+
+
+
+
+
+

Budget Plan / Allocation

+
+
+
+ +
+
+
+
+
+
+
+
+
+ + Settings + ir.actions.act_window + res.config.settings + form + inline + {'module' : 'budget_control', 'bin_size': False} + + +
diff --git a/budget_control/wizards/__init__.py b/budget_control/wizards/__init__.py new file mode 100644 index 00000000..a06e5003 --- /dev/null +++ b/budget_control/wizards/__init__.py @@ -0,0 +1,8 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import generate_budget_control +from . import analytic_budget_info +from . import analytic_budget_edit +from . import confirm_state_budget +from . import budget_commit_forward_info +from . import budget_balance_forward_info diff --git a/budget_control/wizards/analytic_budget_edit.py b/budget_control/wizards/analytic_budget_edit.py new file mode 100644 index 00000000..57f13acd --- /dev/null +++ b/budget_control/wizards/analytic_budget_edit.py @@ -0,0 +1,23 @@ +# Copyright 2020 Ecosoft - (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, fields, models + + +class BudgetAnalyticEdit(models.TransientModel): + _name = "analytic.budget.edit" + _description = "Edit Analytic Budget" + + initial_available = fields.Float(required=True) + reason = fields.Text(required=True) + + def action_edit(self): + active_id = self.env.context.get("active_id") + analytic = self.env["account.analytic.account"].browse(active_id) + if analytic.initial_available == self.initial_available: + return + analytic.write({"initial_available": self.initial_available}) + analytic.message_post( + body=_("Edited initial value. Reason: {}").format(self.reason) + ) + return diff --git a/budget_control/wizards/analytic_budget_edit_view.xml b/budget_control/wizards/analytic_budget_edit_view.xml new file mode 100644 index 00000000..7dff5f9c --- /dev/null +++ b/budget_control/wizards/analytic_budget_edit_view.xml @@ -0,0 +1,31 @@ + + + + + analytic.budget.edit.form + analytic.budget.edit + +
+ +

+ This wizard will edit value and add reason in log note. +

+ + + + +
+
+
+
+
+
+
diff --git a/budget_control/wizards/analytic_budget_info.py b/budget_control/wizards/analytic_budget_info.py new file mode 100644 index 00000000..7d975827 --- /dev/null +++ b/budget_control/wizards/analytic_budget_info.py @@ -0,0 +1,39 @@ +# Copyright 2020 Ecosoft - (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import api, fields, models + + +class BudgetInfo(models.TransientModel): + _name = "analytic.budget.info" + _description = "Show budget overview of selected analytic" + + budget_period_ids = fields.Many2many( + comodel_name="budget.period", + ) + budget_control_ids = fields.Many2many( + comodel_name="budget.control", + readonly=True, + ) + filtered_control_ids = fields.Many2many( + comodel_name="budget.control", + compute="_compute_filtered_control_ids", + ) + + @api.model + def default_get(self, field_list): + res = super().default_get(field_list) + analytic_ids = self.env.context.get("active_ids") + analytics = self.env["account.analytic.account"].browse(analytic_ids) + budget_controls = analytics.mapped("budget_control_ids") + res["budget_control_ids"] = [(6, 0, budget_controls.ids)] + return res + + @api.depends("budget_period_ids") + def _compute_filtered_control_ids(self): + self.ensure_one() + if self.budget_period_ids: + self.filtered_control_ids = self.budget_control_ids.filtered_domain( + [("budget_period_id", "in", self.budget_period_ids.ids)] + ) + else: + self.filtered_control_ids = self.budget_control_ids diff --git a/budget_control/wizards/analytic_budget_info_view.xml b/budget_control/wizards/analytic_budget_info_view.xml new file mode 100644 index 00000000..1cfb438d --- /dev/null +++ b/budget_control/wizards/analytic_budget_info_view.xml @@ -0,0 +1,55 @@ + + + + + analytic.budget.info.form + analytic.budget.info + +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+ + View Budget Info + analytic.budget.info + form + new + + list,form + +
diff --git a/budget_control/wizards/budget_balance_forward_info.py b/budget_control/wizards/budget_balance_forward_info.py new file mode 100644 index 00000000..cb909f7d --- /dev/null +++ b/budget_control/wizards/budget_balance_forward_info.py @@ -0,0 +1,75 @@ +# Copyright 2021 Ecosoft - (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class BudgetBalanceForwardInfo(models.TransientModel): + _name = "budget.balance.forward.info" + _description = "Budget Balance Forward Info" + + forward_id = fields.Many2one( + comodel_name="budget.balance.forward", + index=True, + required=True, + readonly=True, + ondelete="cascade", + ) + forward_info_line_ids = fields.One2many( + comodel_name="budget.balance.forward.info.line", + inverse_name="forward_info_id", + string="Forward Info Lines", + readonly=True, + ) + currency_id = fields.Many2one( + related="forward_id.currency_id", + ) + + def action_budget_balance_forward(self): + self.ensure_one() + self.forward_id.action_budget_balance_forward() + + +class BudgetBalanceForwardInfoLine(models.TransientModel): + _name = "budget.balance.forward.info.line" + _description = "Budget Balance Forward Info Line" + + forward_info_id = fields.Many2one( + comodel_name="budget.balance.forward.info", + index=True, + required=True, + readonly=True, + ondelete="cascade", + ) + analytic_account_id = fields.Many2one( + string="Forward to Analytic", + comodel_name="account.analytic.account", + readonly=True, + ) + analytic_group = fields.Many2one( + comodel_name="account.analytic.group", + string="Analytic Group", + related="analytic_account_id.group_id", + readonly=True, + ) + initial_available = fields.Monetary( + readonly=True, + ) + initial_commit = fields.Monetary( + string="Initial Commitment", + related="analytic_account_id.initial_commit", + readonly=True, + ) + amount_balance = fields.Monetary( + string="Balance", + compute="_compute_amount_balance", + ) + currency_id = fields.Many2one( + comodel_name="res.currency", + related="forward_info_id.currency_id", + readonly=True, + ) + + def _compute_amount_balance(self): + for rec in self: + rec.amount_balance = rec.initial_available - rec.initial_commit diff --git a/budget_control/wizards/budget_balance_forward_info_view.xml b/budget_control/wizards/budget_balance_forward_info_view.xml new file mode 100644 index 00000000..ac8d31cd --- /dev/null +++ b/budget_control/wizards/budget_balance_forward_info_view.xml @@ -0,0 +1,61 @@ + + + + + budget.balance.forward.info.form + budget.balance.forward.info + +
+ + + + + + + + + + + + +
+
+
+
+
+ + Preview Budget Balance + budget.balance.forward.info + form + new + + form + +
diff --git a/budget_control/wizards/budget_commit_forward_info.py b/budget_control/wizards/budget_commit_forward_info.py new file mode 100644 index 00000000..5d01d116 --- /dev/null +++ b/budget_control/wizards/budget_commit_forward_info.py @@ -0,0 +1,76 @@ +# Copyright 2021 Ecosoft - (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class BudgetCommitForwardInfo(models.TransientModel): + _name = "budget.commit.forward.info" + _description = "Budget Commitment Forward Info" + + forward_id = fields.Many2one( + comodel_name="budget.commit.forward", + index=True, + required=True, + readonly=True, + ondelete="cascade", + ) + forward_info_line_ids = fields.One2many( + comodel_name="budget.commit.forward.info.line", + inverse_name="forward_info_id", + string="Forward Info Lines", + readonly=True, + ) + currency_id = fields.Many2one( + related="forward_id.currency_id", + ) + + def action_budget_commit_forward(self): + self.ensure_one() + self.forward_id.action_budget_commit_forward() + + +class BudgetCommitForwardInfoLine(models.TransientModel): + _name = "budget.commit.forward.info.line" + _description = "Budget Commitment Forward Info Line" + + forward_info_id = fields.Many2one( + comodel_name="budget.commit.forward.info", + index=True, + required=True, + readonly=True, + ondelete="cascade", + ) + analytic_account_id = fields.Many2one( + string="Forward to Analytic", + comodel_name="account.analytic.account", + readonly=True, + ) + analytic_group = fields.Many2one( + comodel_name="account.analytic.group", + string="Analytic Group", + related="analytic_account_id.group_id", + readonly=True, + ) + initial_available = fields.Monetary( + string="Initial Available", + related="analytic_account_id.initial_available", + readonly=True, + ) + initial_commit = fields.Monetary( + string="Initial Commitment", + readonly=True, + ) + amount_balance = fields.Monetary( + string="Available", + compute="_compute_amount_balance", + ) + currency_id = fields.Many2one( + comodel_name="res.currency", + related="forward_info_id.currency_id", + readonly=True, + ) + + def _compute_amount_balance(self): + for rec in self: + rec.amount_balance = rec.initial_available - rec.initial_commit diff --git a/budget_control/wizards/budget_commit_forward_info_view.xml b/budget_control/wizards/budget_commit_forward_info_view.xml new file mode 100644 index 00000000..77cafb98 --- /dev/null +++ b/budget_control/wizards/budget_commit_forward_info_view.xml @@ -0,0 +1,61 @@ + + + + + budget.commit.forward.info.form + budget.commit.forward.info + +
+ + + + + + + + + + + + +
+
+
+
+
+ + Preview Budget Commitment + budget.commit.forward.info + form + new + + form + +
diff --git a/budget_control/wizards/confirm_state_budget.py b/budget_control/wizards/confirm_state_budget.py new file mode 100644 index 00000000..0ae85861 --- /dev/null +++ b/budget_control/wizards/confirm_state_budget.py @@ -0,0 +1,33 @@ +# 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 BudgetControlStateConfirmation(models.TransientModel): + _name = "budget.state.confirmation" + _description = "Confirmation State" + + state = fields.Selection( + [ + ("draft", "Draft"), + ("submit", "Submitted"), + ("done", "Controlled"), + ("cancel", "Cancelled"), + ], + string="Status", + required=True, + ) + + def confirm(self): + self.ensure_one() + active_ids = self._context.get("active_ids") + budget_control = self.env["budget.control"].browse(active_ids) + if self.state == "draft": + return budget_control.action_draft() + elif self.state == "done": + return budget_control.action_done() + elif self.state == "submit": + return budget_control.action_submit() + elif self.state == "cancel": + return budget_control.action_cancel() diff --git a/budget_control/wizards/confirm_state_budget_view.xml b/budget_control/wizards/confirm_state_budget_view.xml new file mode 100644 index 00000000..47e1790a --- /dev/null +++ b/budget_control/wizards/confirm_state_budget_view.xml @@ -0,0 +1,28 @@ + + + + Confirm + budget.state.confirmation + +
+ +

+ Please choose the state you want to update. +

+ + + +
+
+
+
+
+
+
diff --git a/budget_control/wizards/generate_budget_control.py b/budget_control/wizards/generate_budget_control.py new file mode 100644 index 00000000..14202489 --- /dev/null +++ b/budget_control/wizards/generate_budget_control.py @@ -0,0 +1,215 @@ +# 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 GenerateBudgetControl(models.TransientModel): + _name = "generate.budget.control" + _description = "Generate Budget Control Sheets" + + budget_period_id = fields.Many2one( + comodel_name="budget.period", + required=True, + ondelete="cascade", + ) + state = fields.Selection( + [("choose", "choose"), ("get", "get")], + default="choose", + ) + analytic_group_ids = fields.Many2many( + comodel_name="account.analytic.group", + relation="analytic_group_generate_budget_control_rel", + column1="wizard_id", + column2="group_id", + ) + all_analytic_accounts = fields.Boolean( + help="Generate budget control sheet for all missing analytic account", + ) + domain_analytic_account_ids = fields.Many2many( + comodel_name="account.analytic.account", + compute="_compute_domain_analytic_account_ids", + ) + analytic_account_ids = fields.Many2many( + comodel_name="account.analytic.account", + relation="analytic_generate_budget_control_rel", + column1="wizard_id", + column2="anlaytic_id", + domain="[('id', 'in', domain_analytic_account_ids)]", + ) + init_budget_commit = fields.Boolean( + string="Initial Budget By Commit Amount", + help="If checked, the newly created budget control sheet will has " + "initial budget equal to current budget commitment of its year.", + ) + init_kpis = fields.Boolean( + string="With Initial KPIs", + help="When each budget control sheet is created, prefill lines " + "with Intial KPIs", + ) + use_all_kpis = fields.Boolean(string="Use All KPIs") + template_id = fields.Many2one( + comodel_name="budget.template", + required=True, + ondelete="cascade", + ) + template_line_ids = fields.Many2many( + comodel_name="budget.template.line", + relation="kpi_generate_budget_control_rel", + column1="wizard_id", + column2="kpi_id", + domain="[('template_id', '=', template_id)]", + ) + result_analytic_account_ids = fields.Many2many( + comodel_name="account.analytic.account", + relation="result_analytic_generate_budget_control_rel", + column1="wizard_id", + column2="anlaytic_id", + readonly=True, + help="Analytics not created by this operation, as they already exisits", + ) + result_budget_control_ids = fields.Many2many( + comodel_name="budget.control", + relation="result_budget_generate_budget_control_rel", + column1="wizard_id", + column2="budget_control_id", + readonly=True, + help="Budget Control Sheets created by this operation", + ) + + @api.model + def default_get(self, default_fields): + values = super().default_get(default_fields) + period_id = self.env.context.get("active_id") + period = self.env["budget.period"].browse(period_id) + period._check_budget_period_date_range() + values["budget_period_id"] = period.id + values["template_id"] = period.template_id.id + return values + + @api.depends("analytic_group_ids", "budget_period_id") + def _compute_domain_analytic_account_ids(self): + Analytic = self.env["account.analytic.account"] + for rec in self: + analytics = Analytic.search( + [("group_id", "in", self.analytic_group_ids.ids)] + ) + analytics = analytics.filtered_domain( + [ + "|", + ("bm_date_to", ">=", self.budget_period_id.bm_date_to), + ("bm_date_to", "=", False), + ] + ) + analytics = analytics.filtered_domain( + [ + "|", + ("bm_date_from", "<=", self.budget_period_id.bm_date_from), + ("bm_date_from", "=", False), + ] + ) + rec.domain_analytic_account_ids = analytics + + @api.onchange("all_analytic_accounts", "analytic_group_ids") + def _onchange_analytic_accounts(self): + """Auto fill analytic_account_ids.""" + self.analytic_account_ids = False + if self.all_analytic_accounts: + self.analytic_account_ids = self.domain_analytic_account_ids + + @api.onchange("use_all_kpis") + def _onchange_use_all_kpis(self): + self.template_line_ids = False + if self.use_all_kpis: + self.template_line_ids = self.env["budget.template.line"].search( + [ + ("template_id", "=", self.template_id.id), + ] + ) + + def _get_budget_period_name(self): + budget_name = "{} :: ".format(self.budget_period_id.name) + return budget_name + + def _prepare_value_duplicate(self, vals): + plan_date_range_id = self.budget_period_id.plan_date_range_type_id.id + # Just in case not budget name + budget_name = self._get_budget_period_name() + use_all_kpis = self.use_all_kpis + budget_period_id = self.budget_period_id.id + template_lines = self.template_line_ids.ids + return list( + map( + lambda l: { + "name": "{}".format( + budget_name + and budget_name + l["analytic_account_id"].name + or l["analytic_account_id"].name + ), + "analytic_account_id": l["analytic_account_id"].id, + "plan_date_range_type_id": plan_date_range_id, + "use_all_kpis": use_all_kpis, + "template_line_ids": template_lines, + "budget_period_id": budget_period_id, + }, + vals, + ) + ) + + def _prepare_value(self, analytic): + return [{"analytic_account_id": x} for x in analytic] + + def _prepare_budget_control_sheet(self, analytic): + vals = self._prepare_value(analytic) + return self._prepare_value_duplicate(vals) + + def _get_existing_budget(self): + BudgetControl = self.env["budget.control"] + existing_budget_controls = BudgetControl.search( + [ + ("template_id", "=", self.template_id.id), + ("budget_period_id", "=", self.id), + ("analytic_account_id", "in", self.analytic_account_ids.ids), + ] + ) + return existing_budget_controls + + def _create_budget_controls(self, vals): + return self.env["budget.control"].create(vals) + + def action_generate_budget_control(self): + """Create new draft budget control sheet for all selected analytics.""" + self.ensure_one() + # Find existing controls, so we can skip. + existing_budget_controls = self._get_existing_budget() + existing_analytics = existing_budget_controls.mapped("analytic_account_id") + # Create budget controls that are not already exists + new_analytic = self.analytic_account_ids - existing_analytics + vals = self._prepare_budget_control_sheet(new_analytic) + budget_controls = self._create_budget_controls(vals) + budget_controls.prepare_budget_control_matrix() + budget_controls.do_init_budget_commit(self.init_budget_commit) + # Return result + self.write( + { + "state": "get", + "result_analytic_account_ids": [(6, 0, existing_analytics.ids)], + "result_budget_control_ids": [(6, 0, budget_controls.ids)], + } + ) + return { + "type": "ir.actions.act_window", + "res_model": self._name, + "view_mode": "form", + "view_type": "form", + "res_id": self.id, + "views": [(False, "form")], + "target": "new", + } + + def action_view_budget_control(self): + self.ensure_one() + action = self.env["ir.actions.act_window"]._for_xml_id( + "budget_control.budget_control_action" + ) + action["domain"] = [("id", "in", self.result_budget_control_ids.ids)] + return action diff --git a/budget_control/wizards/generate_budget_control_view.xml b/budget_control/wizards/generate_budget_control_view.xml new file mode 100644 index 00000000..014b0679 --- /dev/null +++ b/budget_control/wizards/generate_budget_control_view.xml @@ -0,0 +1,96 @@ + + + + Generate Budget Control Sheets + generate.budget.control + +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + +
+
+
+ +
+
+ + Generate Budget Control Sheet + generate.budget.control + form + + form + new + {'default_init_kpis': True, 'default_use_all_kpis': True} + +
From 49f49bf86fe3a26e1fc600453159627cf5192cc4 Mon Sep 17 00:00:00 2001 From: Saran440 Date: Wed, 3 Jan 2024 17:15:15 +0700 Subject: [PATCH 02/24] [FIX] budget_control: close budget with fwd_analytic_account_id --- budget_control/models/base_budget_move.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/budget_control/models/base_budget_move.py b/budget_control/models/base_budget_move.py index 29d021de..956ed7be 100644 --- a/budget_control/models/base_budget_move.py +++ b/budget_control/models/base_budget_move.py @@ -572,4 +572,6 @@ def close_budget_move(self): use_amount_commit=True, commit_note=_("Auto adjustment on close budget"), adj_commit=True, - ).commit_budget(reverse=True) + ).commit_budget( + reverse=True, analytic_account_id=docline.fwd_analytic_account_id + ) From 152ea1720012094d4e611c9f87cc28cb167ed05a Mon Sep 17 00:00:00 2001 From: Saran440 Date: Sun, 11 Feb 2024 17:36:29 +0700 Subject: [PATCH 03/24] [FIX] budget_control: check budget with sudo() --- budget_control/models/budget_period.py | 1 + 1 file changed, 1 insertion(+) diff --git a/budget_control/models/budget_period.py b/budget_control/models/budget_period.py index e8a4ef8b..f09dcfbd 100644 --- a/budget_control/models/budget_period.py +++ b/budget_control/models/budget_period.py @@ -212,6 +212,7 @@ def check_budget_precommit(self, doclines, doc_type="account"): first do the normal commit, do checking, and remove commits""" if not doclines: return + doclines = doclines.sudo() # Commit budget budget_moves = [] vals_date_commit = [] From 99a265b2c3724f44fb8dc83e0a6d9838d3985e58 Mon Sep 17 00:00:00 2001 From: Saran440 Date: Thu, 25 Apr 2024 14:49:27 +0700 Subject: [PATCH 04/24] [FIX] budget_control_purchase: add precommit check budget function Support for other module can check budget precommit in PO and Actual. Standard will not check precommit because PO and Actual has next state draft is commit budget. So, precommit budget is not need. However, extensnion module like 'base_tier_validation_check_budget' can check budget in state draft. we will improved base module budget for support precommit check budget --- budget_control/models/account_move_line.py | 4 ++++ budget_control/models/budget_period.py | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/budget_control/models/account_move_line.py b/budget_control/models/account_move_line.py index 3318c7fd..118a4424 100644 --- a/budget_control/models/account_move_line.py +++ b/budget_control/models/account_move_line.py @@ -64,3 +64,7 @@ def _get_included_tax(self): 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/budget_period.py b/budget_control/models/budget_period.py index f09dcfbd..c96a7f7c 100644 --- a/budget_control/models/budget_period.py +++ b/budget_control/models/budget_period.py @@ -213,6 +213,11 @@ def check_budget_precommit(self, doclines, doc_type="account"): if not doclines: return doclines = doclines.sudo() + # Allow precommit budget with related origin document (PO) + if doc_type == "account": + budget_moves_uncommit = doclines.with_context( + force_commit=True + ).uncommit_purchase_budget() # Commit budget budget_moves = [] vals_date_commit = [] @@ -231,6 +236,9 @@ def check_budget_precommit(self, doclines, doc_type="account"): 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): From ba0e7322916f770ee80ba7b2d409898630a12f2f Mon Sep 17 00:00:00 2001 From: Saran440 Date: Thu, 25 Apr 2024 18:46:05 +0700 Subject: [PATCH 05/24] [FIX] check budget with period --- budget_control/models/budget_period.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/budget_control/models/budget_period.py b/budget_control/models/budget_period.py index c96a7f7c..185fbe81 100644 --- a/budget_control/models/budget_period.py +++ b/budget_control/models/budget_period.py @@ -426,7 +426,11 @@ def _check_budget_available(self, controls, budget_period): _("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) + 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 From 4861601dabf0e0398bbabd4ac422fabf0ca3cdc8 Mon Sep 17 00:00:00 2001 From: Saran440 Date: Fri, 26 Apr 2024 09:52:28 +0700 Subject: [PATCH 06/24] [FIX] error no variable --- budget_control/models/budget_period.py | 1 + 1 file changed, 1 insertion(+) diff --git a/budget_control/models/budget_period.py b/budget_control/models/budget_period.py index 185fbe81..2c721ebf 100644 --- a/budget_control/models/budget_period.py +++ b/budget_control/models/budget_period.py @@ -213,6 +213,7 @@ def check_budget_precommit(self, doclines, doc_type="account"): if not doclines: return doclines = doclines.sudo() + budget_moves_uncommit = False # Allow precommit budget with related origin document (PO) if doc_type == "account": budget_moves_uncommit = doclines.with_context( From 1d26eaffc44503313027e06ace8d9e8f2b953c3c Mon Sep 17 00:00:00 2001 From: "@" <@> Date: Thu, 2 May 2024 23:00:00 +0700 Subject: [PATCH 07/24] [FIX] budget_control: error no date commit on convert currency amount --- budget_control/models/base_budget_move.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/budget_control/models/base_budget_move.py b/budget_control/models/base_budget_move.py index 956ed7be..b200f568 100644 --- a/budget_control/models/base_budget_move.py +++ b/budget_control/models/base_budget_move.py @@ -309,18 +309,18 @@ def _update_budget_commitment(self, budget_vals, reverse=False): ) 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 + budget_vals["amount_currency"], currency, company, date_commit or today ) # 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 - today = fields.Date.context_today(self) res = { "product_id": self.product_id.id, "account_id": account.id, From 5d0aa686a9a6a6923f3125ae3522f8de036551c9 Mon Sep 17 00:00:00 2001 From: Saran440 Date: Wed, 8 May 2024 12:08:07 +0700 Subject: [PATCH 08/24] [FIX] budget_control: not check required analytic with display_type --- budget_control/models/base_budget_move.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/budget_control/models/base_budget_move.py b/budget_control/models/base_budget_move.py index b200f568..08036edd 100644 --- a/budget_control/models/base_budget_move.py +++ b/budget_control/models/base_budget_move.py @@ -437,9 +437,10 @@ def commit_budget(self, reverse=False, **vals): required_analytic = self.env.user.has_group( "budget_control.group_required_analytic" ) - # Required all document except move type entry + # Required all document except move type entry or 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.move_type == "entry" From d177f693bd659673983cbf34a4d1fb02962d2f71 Mon Sep 17 00:00:00 2001 From: Saran440 Date: Fri, 7 Jun 2024 12:02:05 +0700 Subject: [PATCH 09/24] [FIX] pre-commit --- budget_control/models/budget_control.py | 18 +++++++++++------- budget_control/models/budget_period.py | 8 ++++++-- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/budget_control/models/budget_control.py b/budget_control/models/budget_control.py index afc164a0..b0162139 100644 --- a/budget_control/models/budget_control.py +++ b/budget_control/models/budget_control.py @@ -241,9 +241,10 @@ def _check_budget_control_over_consumed(self): ) if budget_info["amount_balance"] < 0: raise UserError( - _("Total amount in KPI {} will result in {:,.2f}").format( - line.name, budget_info["amount_balance"] - ) + _( + "Total amount in KPI %(name)s will result in {:,.2f}", + name=line.name, + ).format(budget_info["amount_balance"]) ) @api.onchange("use_all_kpis") @@ -365,8 +366,10 @@ def _check_budget_amount(self): ): raise UserError( _( - "Planning amount should equal to the released amount {:,.2f} {}" - ).format(rec.released_amount, rec.currency_id.symbol) + "Planning amount should equal " + "to the released amount {:,.2f} %(symbol)s", + symbol=rec.currency_id.symbol, + ).format(rec.released_amount) ) # Check plan vs intial if ( @@ -380,8 +383,9 @@ def _check_budget_amount(self): raise UserError( _( "Planning amount should be greater than " - "initial balance {:,.2f} {}" - ).format(rec.amount_initial, rec.currency_id.symbol) + "initial balance {:,.2f} %(symbol)s", + symbol=rec.currency_id.symbol, + ).format(rec.amount_initial) ) def action_draft(self): diff --git a/budget_control/models/budget_period.py b/budget_control/models/budget_period.py index 2c721ebf..e9864e35 100644 --- a/budget_control/models/budget_period.py +++ b/budget_control/models/budget_period.py @@ -438,7 +438,7 @@ def _check_budget_available(self, controls, budget_period): balance_currency = self._get_balance_currency( company, balance, doc_currency, date_commit ) - fomatted_balance = format_amount( + formatted_balance = format_amount( self.env, balance_currency, doc_currency ) analytic_name = Analytic.browse(analytic_id).display_name @@ -447,7 +447,11 @@ def _check_budget_available(self, controls, budget_period): template_lines.display_name, analytic_name ) warnings.append( - _("{0}, will result in {1}").format(analytic_name, fomatted_balance) + _( + "%(analytic_name)s, will result in %(formatted_balance)s", + analytic_name=analytic_name, + formatted_balance=formatted_balance, + ) ) return list(set(warnings)) From 1834dfd5336afe1fa9510885b6f3116479896e72 Mon Sep 17 00:00:00 2001 From: Saran440 Date: Fri, 7 Jun 2024 11:19:51 +0700 Subject: [PATCH 10/24] [FIX] budget_control: commit budget revenue case --- budget_control/models/base_budget_move.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/budget_control/models/base_budget_move.py b/budget_control/models/base_budget_move.py index 08036edd..6732dca0 100644 --- a/budget_control/models/base_budget_move.py +++ b/budget_control/models/base_budget_move.py @@ -318,6 +318,14 @@ def _update_budget_commitment(self, budget_vals, reverse=False): 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 From a1f71f4fb7acbda3ad392465f42287d13908fae4 Mon Sep 17 00:00:00 2001 From: Saran440 Date: Thu, 18 Jul 2024 13:55:53 +0700 Subject: [PATCH 11/24] [MIG] budget_control: Migration to 16.0 --- budget_control/README.rst | 60 ++--- budget_control/__manifest__.py | 7 +- budget_control/models/account_move.py | 18 +- budget_control/models/account_move_line.py | 13 +- budget_control/models/analytic_account.py | 27 +- budget_control/models/base_budget_move.py | 236 +++++++++++------- .../models/budget_commit_forward.py | 155 ++++++++---- budget_control/models/budget_control.py | 35 +-- .../models/budget_move_adjustment.py | 26 +- budget_control/models/budget_period.py | 53 +++- budget_control/models/budget_transfer.py | 15 +- budget_control/readme/DESCRIPTION.rst | 19 +- budget_control/readme/USAGE.rst | 26 +- .../report/budget_monitor_report.py | 8 +- .../report/budget_monitor_report_view.xml | 6 +- budget_control/report/budget_move_views.xml | 9 +- budget_control/static/description/index.html | 71 +++--- .../static/src/xml/budget_popover.xml | 69 ++--- budget_control/tests/common.py | 128 ++++++---- budget_control/tests/test_budget_control.py | 58 +++-- budget_control/views/account_budget_move.xml | 44 ++++ budget_control/views/account_move_views.xml | 31 +-- .../views/analytic_account_views.xml | 22 +- .../views/budget_balance_forward_view.xml | 6 +- .../views/budget_commit_forward_view.xml | 26 +- budget_control/views/budget_control_view.xml | 123 +++++---- budget_control/views/budget_menuitem.xml | 2 +- .../views/budget_move_adjustment_view.xml | 24 +- budget_control/views/budget_period_view.xml | 8 +- .../wizards/analytic_budget_info_view.xml | 38 ++- .../wizards/budget_balance_forward_info.py | 7 +- .../budget_balance_forward_info_view.xml | 2 +- .../wizards/budget_commit_forward_info.py | 9 +- .../budget_commit_forward_info_view.xml | 4 +- .../wizards/generate_budget_control.py | 16 +- .../wizards/generate_budget_control_view.xml | 2 +- 36 files changed, 780 insertions(+), 623 deletions(-) create mode 100644 budget_control/views/account_budget_move.xml diff --git a/budget_control/README.rst b/budget_control/README.rst index 4265cd45..8cfae411 100644 --- a/budget_control/README.rst +++ b/budget_control/README.rst @@ -7,7 +7,7 @@ Budget Control !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:170b7aa450e2ccdfa27c0d5840cf49a8511a46198988caa073e23f03a6689384 + !! source digest: sha256:353c8401879120bf194a5135e6f67838a807a00dc09ec0d8388ce36d90910040 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png @@ -17,7 +17,7 @@ Budget Control :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/15.0/budget_control + :target: https://github.com/ecosoft-odoo/budgeting/tree/16.0/budget_control :alt: ecosoft-odoo/budgeting |badge1| |badge2| |badge3| @@ -43,16 +43,11 @@ Budget Control Core Features: for approved expense. Note that, in this budget_control module, there is no extension for budget commitment yet. -* **Budget KPI (budget.kpi)** - - Budget KPI is used to measure the efficiency of planning compared to actual usage. - It is linked to Account Codes, and one Budget KPI can be associated with more than one account code. - * **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 budget kpi and accounting, + The budget template has a relationship with the accounting, and is used to control spending based on pre-configured accounts. * **Budget Period (budget.period)** @@ -117,11 +112,10 @@ Following are brief explanation of what the extended module will do. These modules extend base.budget.move for other document budget commitment. -* budget_control_advance_clearing -* budget_control_contract * budget_control_expense * budget_control_purchase * budget_control_purchase_request +* budget_control_sale **Budget Allocation** @@ -130,11 +124,6 @@ until set budget control. and allow create Master Data source of fund, analytic Users can view source of fund monitoring report * budget_allocation -* budget_allocation_advance_clearing -* budget_allocation_contract -* budget_allocation_expense -* budget_allocation_purchase -* budget_allocation_purchase_request **Tier Validation** @@ -153,6 +142,10 @@ we can use dimension to create persistent dimension columns 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. @@ -167,15 +160,14 @@ 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 + - 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 + To create budget KPI using in budget template 2. Create new Budget Template @@ -190,29 +182,26 @@ Followings are sample steps to start with, 4. Create Budget Control Sheet - To create budget control sheet, you can create by using the helper, + 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 Group - - Check All Analytic Accounts, this will list all analytic account in selected groups + - 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 "Generate Budget Control Sheet", and then view the newly created control sheets. + - 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" or by Menu > Budgeting > Budget Control Sheet, to open them. + icon "Budget Control Sheets" or by Menu > Budgeting > Budget Control Sheet, to open them. - - Within the "Plan Date Range" period, the Plan table displays all KPIs split by Plan Date Range - - If you need to edit the plan, click the "Reset Options" tab, then select the KPIs you want to plan - - Click the "Soft Reset" button to generate KPIs. The amounts in the plan table will not disappear. - - Click the "Hard Reset" button to generate KPIs. The amounts in the plan table will disappear. + - Based on "Plan Date Range" period, Plan table will show all KPI split by Plan Date Range - Allocate budget amount as appropriate. - - Click Submit > Control, state will change to Controlled. + - 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 "Soft Reset" or "Hard Reset" anytime. + Once ready, you can click on "Reset Plan" anytime. 6. Budget Reports @@ -232,7 +221,7 @@ 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 `_. +`feedback `_. Do not contact contributors directly about support or help with technical issues. @@ -256,11 +245,14 @@ 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 maintainer: +Current maintainers: -|maintainer-kittiu| +|maintainer-kittiu| |maintainer-ru3ix-bbb| -This module is part of the `ecosoft-odoo/budgeting `_ project on GitHub. +This module is part of the `ecosoft-odoo/budgeting `_ project on GitHub. You are welcome to contribute. diff --git a/budget_control/__manifest__.py b/budget_control/__manifest__.py index a2cf2123..3c11361a 100644 --- a/budget_control/__manifest__.py +++ b/budget_control/__manifest__.py @@ -3,7 +3,7 @@ { "name": "Budget Control", - "version": "15.0.1.0.0", + "version": "16.0.1.0.0", "category": "Accounting", "license": "AGPL-3", "author": "Ecosoft, Odoo Community Association (OCA)", @@ -25,6 +25,7 @@ "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", @@ -45,12 +46,12 @@ ], "demo": ["demo/budget_template_demo.xml"], "assets": { - "web.assets_qweb": [ + "web.assets_backend": [ "budget_control/static/src/xml/budget_popover.xml", ], }, "installable": True, - "maintainers": ["kittiu"], + "maintainers": ["kittiu", "ru3ix-bbb"], "post_init_hook": "update_data_hooks", "uninstall_hook": "uninstall_hook", "development_status": "Alpha", diff --git a/budget_control/models/account_move.py b/budget_control/models/account_move.py index 756389dc..11a19d6d 100644 --- a/budget_control/models/account_move.py +++ b/budget_control/models/account_move.py @@ -46,17 +46,16 @@ def recompute_budget_move(self): def close_budget_move(self): self.mapped("invoice_line_ids").close_budget_move() - @api.model - def create(self, vals): + @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. """ - not_affect_budget = vals.get("not_affect_budget", "None") - journal_id = vals.get("journal_id") - if not_affect_budget == "None" and journal_id: - journal = self.env["account.journal"].browse(journal_id) - vals["not_affect_budget"] = journal.not_affect_budget - return super().create(vals) + 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): """ @@ -87,7 +86,8 @@ def _filtered_move_check_budget(self): def action_post(self): res = super().action_post() - self.flush() + # 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) diff --git a/budget_control/models/account_move_line.py b/budget_control/models/account_move_line.py index 118a4424..b69e252d 100644 --- a/budget_control/models/account_move_line.py +++ b/budget_control/models/account_move_line.py @@ -36,25 +36,24 @@ def recompute_budget_move(self): # Commit on invoice invoice_line.commit_budget() - def _init_docline_budget_vals(self, budget_vals): + def _init_docline_budget_vals(self, budget_vals, analytic_id): self.ensure_one() if self.move_id.move_type == "entry": - budget_vals["amount_currency"] = self.amount_currency + 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 - budget_vals["amount_currency"] = ( - sign * self.price_unit * self.quantity * discount - ) + 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, - "analytic_tag_ids": [(6, 0, self.analytic_tag_ids.ids)], } ) - return super()._init_docline_budget_vals(budget_vals) + return super()._init_docline_budget_vals(budget_vals, analytic_id) def _valid_commit_state(self): return self.move_id.state == "posted" diff --git a/budget_control/models/analytic_account.py b/budget_control/models/analytic_account.py index ba5259e4..d23e14c1 100644 --- a/budget_control/models/analytic_account.py +++ b/budget_control/models/analytic_account.py @@ -166,6 +166,7 @@ def _find_next_analytic(self, next_date_range): 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( [ @@ -173,11 +174,20 @@ def _update_val_analytic(self, next_analytic, next_date_range): ("plan_date_range_type_id", "=", type_id.id), ] ) - return {"budget_period_id": period_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() - next_analytic = self.copy() + # 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 @@ -230,12 +240,13 @@ def _compute_bm_date(self): rec.bm_date_to = rec.budget_period_id.bm_date_to def _auto_adjust_date_commit(self, docline): - self.ensure_one() - if self.auto_adjust_date_commit: - if self.bm_date_from and self.bm_date_from > docline.date_commit: - docline.date_commit = self.bm_date_from - elif self.bm_date_to and self.bm_date_to < docline.date_commit: - docline.date_commit = self.bm_date_to + 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 { diff --git a/budget_control/models/base_budget_move.py b/budget_control/models/base_budget_move.py index 6732dca0..665a2c8c 100644 --- a/budget_control/models/base_budget_move.py +++ b/budget_control/models/base_budget_move.py @@ -34,7 +34,7 @@ class BaseBudgetMove(models.AbstractModel): ) kpi_id = fields.Many2one( comodel_name="budget.kpi", - related="template_line_id.kpi_id", + compute="_compute_kpi_id", store=True, ) date = fields.Date( @@ -58,16 +58,12 @@ class BaseBudgetMove(models.AbstractModel): index=True, readonly=True, ) - analytic_group = fields.Many2one( - comodel_name="account.analytic.group", + analytic_plan = fields.Many2one( + comodel_name="account.analytic.plan", auto_join=True, index=True, readonly=True, ) - analytic_tag_ids = fields.Many2many( - comodel_name="account.analytic.tag", - string="Analytic Tags", - ) amount_currency = fields.Float( required=True, help="Amount in multi currency", @@ -95,6 +91,11 @@ class BaseBudgetMove(models.AbstractModel): 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}) @@ -109,7 +110,7 @@ class BudgetDoclineMixinBase(models.AbstractModel): _description = ( "Base of budget.docline.mixin, used for non budgeting model extension" ) - _budget_analytic_field = "analytic_account_id" + _budget_analytic_field = "analytic_distribution" # Budget related variables _budget_date_commit_fields = [] # Date used for budget commitment _budget_move_model = False # account.budget.move @@ -131,7 +132,7 @@ class BudgetDoclineMixin(models.AbstractModel): compute="_compute_can_commit", help="If True, this docline is eligible to create budget move", ) - amount_commit = fields.Float( + amount_commit = fields.Json( compute="_compute_commit", copy=False, store=True, @@ -146,12 +147,9 @@ class BudgetDoclineMixin(models.AbstractModel): compute="_compute_auto_adjust_date_commit", readonly=True, ) - fwd_analytic_account_id = fields.Many2one( - comodel_name="account.analytic.account", + fwd_analytic_distribution = fields.Json( string="Carry Forward Analytic", copy=False, - readonly=False, - index=True, help="If specified, recompute budget will take this into account", ) fwd_date_commit = fields.Date( @@ -174,16 +172,26 @@ def _budget_field(self): def _valid_commit_state(self): raise ValidationError(_("No implementation error!")) - @api.onchange("fwd_analytic_account_id") - def _onchange_fwd_analytic_account_id(self): - self.fwd_date_commit = self.fwd_analytic_account_id.bm_date_from + 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: - docline.auto_adjust_date_commit = docline[ - self._budget_analytic_field - ].auto_adjust_date_commit + 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): @@ -208,9 +216,25 @@ def _compute_commit(self): - Calc date_commit if not exists and on 1st budget_move_ids only or False """ for rec in self: - debit = sum(rec.budget_move_ids.mapped("debit")) - credit = sum(rec.budget_move_ids.mapped("credit")) - rec.amount_commit = debit - credit + 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: @@ -219,30 +243,42 @@ def _compute_commit(self): def _compute_json_budget_popover(self): FloatConverter = self.env["ir.qweb.field.float"] for rec in self: - analytic = rec[self._budget_analytic_field] - if not analytic: + 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 ) - analytic = analytic.with_context(budget_period_ids=[budget_period.id]) rec.json_budget_popover = dumps( { "title": _("Budget Figure"), "icon": "fa-info-circle", "popoverTemplate": "budget_control.budgetPopOver", - "analytic": analytic.display_name, - "budget": FloatConverter.value_to_html( - analytic.amount_budget, {"decimal_precision": "Product Price"} - ), - "consumed": FloatConverter.value_to_html( - analytic.amount_consumed, {"decimal_precision": "Product Price"} - ), - "balance": FloatConverter.value_to_html( - analytic.amount_balance, {"decimal_precision": "Product Price"} - ), + "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] + ) + ], } ) @@ -274,7 +310,7 @@ def _set_date_commit(self): return if not self._budget_date_commit_fields: raise ValidationError(_("'_budget_date_commit_fields' is not set!")) - analytic = docline[self._budget_analytic_field] + 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 @@ -294,14 +330,10 @@ def _get_amount_convert_currency( amount_currency, company.currency_id, company, date_commit ) - def _update_budget_commitment(self, budget_vals, reverse=False): + def _update_budget_commitment(self, budget_vals, analytic, reverse=False): self.ensure_one() company = self.env.user.company_id account = self.account_id - # Check params analytic_account_id, if not it should be self analytic - analytic_account = budget_vals.get("analytic_account_id", False) - if not analytic_account: - analytic_account = self[self._budget_analytic_field] budget_moves = self[self._budget_field()] date_commit = budget_vals.get( "date", @@ -318,22 +350,20 @@ def _update_budget_commitment(self, budget_vals, reverse=False): 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_account.id, - "analytic_group": analytic_account.group_id.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, @@ -361,6 +391,8 @@ def _update_template_line(self, budget_move): 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): @@ -376,14 +408,13 @@ def forward_commit(self): ForwardLine = self.env["budget.commit.forward.line"] BudgetPeriod = self.env["budget.period"] for docline in self: - if not docline.fwd_analytic_account_id or not docline.fwd_date_commit: + if not docline.fwd_analytic_distribution or not docline.fwd_date_commit: return if ( - docline[self._budget_analytic_field] == docline.fwd_analytic_account_id + docline[self._budget_analytic_field] + == docline.fwd_analytic_distribution and docline.date_commit == docline.fwd_date_commit ): # no forward to same date - # docline.fwd_analytic_account_id = False - # docline.fwd_date_commit = False return domain_fwd_line = self._get_domain_fwd_line(docline) fwd_lines = ForwardLine.search(domain_fwd_line) @@ -445,48 +476,70 @@ def commit_budget(self, reverse=False, **vals): required_analytic = self.env.user.has_group( "budget_control.group_required_analytic" ) - # Required all document except move type entry or display_type is not false + # 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.move_type == "entry" + self._name == "account.move.line" + and (self.move_id.not_affect_budget or self.tax_line_id) ) - and not self._context.get("bypass_required_analytic") ): 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: - # Set amount_currency - budget_vals = self._init_docline_budget_vals(vals) - # 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 - if self.env.context.get("fwd_amount_commit"): - budget_vals["amount_currency"] = self.env.context.get( - "fwd_amount_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) ) - # 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" + # 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 ) - # Complete budget commitment dict - budget_vals = self._update_budget_commitment(budget_vals, 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_move = self.env[self._budget_model()].create(budget_vals) + # 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 @@ -498,7 +551,7 @@ def commit_budget(self, reverse=False, **vals): def _required_fields_to_commit(self): return [self._budget_analytic_field] - def _init_docline_budget_vals(self, budget_vals): + 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!")) @@ -557,19 +610,20 @@ def _check_date_commit(self): """Commit date must inline with analytic account""" self.ensure_one() docline = self - analytic = docline[self._budget_analytic_field] - if analytic: + analytics = docline._convert_analytics() + if analytics: if not docline.date_commit: raise UserError(_("No budget commitment date")) - 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 - ) + 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")) @@ -582,5 +636,5 @@ def close_budget_move(self): commit_note=_("Auto adjustment on close budget"), adj_commit=True, ).commit_budget( - reverse=True, analytic_account_id=docline.fwd_analytic_account_id + reverse=True, analytic_distribution=docline.fwd_analytic_distribution ) diff --git a/budget_control/models/budget_commit_forward.py b/budget_control/models/budget_commit_forward.py index 2e854aff..8fcbf515 100644 --- a/budget_control/models/budget_commit_forward.py +++ b/budget_control/models/budget_commit_forward.py @@ -27,6 +27,9 @@ class BudgetCommitForward(models.Model): 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"), @@ -58,14 +61,6 @@ class BudgetCommitForward(models.Model): _sql_constraints = [ ("name_uniq", "UNIQUE(name)", "Name must be unique!"), ] - total_commitment = fields.Monetary( - compute="_compute_total_commitment", - ) - - @api.depends("forward_line_ids") - def _compute_total_commitment(self): - for rec in self: - rec.total_commitment = sum(rec.forward_line_ids.mapped("amount_commit")) def _compute_missing_analytic(self): for rec in self: @@ -75,19 +70,43 @@ def _compute_missing_analytic(self): ) ) - def _get_base_domain(self): + def _get_base_from_extension(self, res_model): """For module extension""" - self.ensure_one() - domain = [ - ("amount_commit", ">", 0.0), - ("date_commit", "<", self.to_date_commit), - ("fwd_date_commit", "!=", self.to_date_commit), - ] - return domain + return "" - def _get_commit_docline(self, res_model): + def _get_base_domain_extension(self, res_model): """For module extension""" - return [] + 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""" @@ -101,29 +120,30 @@ def _get_budget_docline_model(self): 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_account_id or doc[doc._budget_analytic_field] - ) - method_type = False - if ( - analytic_account.bm_date_to - and analytic_account.bm_date_to < self.to_date_commit - ): - method_type = "new" - value_dict.append( - { - "forward_id": self.id, - "analytic_account_id": analytic_account.id, - "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, - "date_commit": doc.fwd_date_commit or doc.date_commit, - } + 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): @@ -132,6 +152,10 @@ def action_review_budget_commit(self): 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() @@ -201,18 +225,32 @@ def _get_forward_initial_commit(self, domain): 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: - line.document_id.write( + 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_account_id": reverse - and line.analytic_account_id - or line.to_analytic_account_id, + "fwd_analytic_distribution": fwd_analytic_distribution, "fwd_date_commit": reverse - and line.date_commit + 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 @@ -222,12 +260,12 @@ 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 - Analytic = self.env["account.analytic.account"] + 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 = Analytic.browse(val["analytic_account_id"]) + analytic = AnalyticAccount.browse(val["analytic_account_id"]) analytic.initial_commit -= val["initial_commit"] return forward_duplicate = self.env["budget.commit.forward"].search( @@ -240,7 +278,7 @@ def _do_update_initial_commit(self, reverse=False): domain.append(("forward_id.state", "in", ["review", "done"])) forward_vals = self._get_forward_initial_commit(domain) for val in forward_vals: - analytic = Analytic.browse(val["analytic_account_id"]) + 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"] @@ -285,6 +323,7 @@ def action_draft(self): 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", @@ -300,6 +339,9 @@ class BudgetCommitForwardLine(models.Model): required=True, readonly=True, ) + analytic_percent = fields.Float( + readonly=True, + ) method_type = fields.Selection( selection=[ ("new", "New"), @@ -314,11 +356,9 @@ class BudgetCommitForwardLine(models.Model): string="Forward to 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, + compute="_compute_bm_date_to", ) res_model = fields.Selection( selection=[], @@ -357,6 +397,11 @@ class BudgetCommitForwardLine(models.Model): 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: @@ -382,3 +427,15 @@ def _compute_to_analytic_account_id(self): 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_control.py b/budget_control/models/budget_control.py index b0162139..c79e0f1a 100644 --- a/budget_control/models/budget_control.py +++ b/budget_control/models/budget_control.py @@ -51,13 +51,9 @@ class BudgetControl(models.Model): tracking=True, ondelete="restrict", ) - analytic_tag_ids = fields.Many2many( - comodel_name="account.analytic.tag", string="Analytic Tags" - ) - analytic_group = fields.Many2one( - comodel_name="account.analytic.group", - string="Analytic Group", - related="analytic_account_id.group_id", + analytic_plan = fields.Many2one( + comodel_name="account.analytic.plan", + related="analytic_account_id.plan_id", store=True, ) line_ids = fields.One2many( @@ -191,7 +187,6 @@ class BudgetControl(models.Model): @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""" - self.flush() query = """ SELECT analytic_account_id, budget_period_id, COUNT(*) FROM budget_control @@ -242,9 +237,10 @@ def _check_budget_control_over_consumed(self): if budget_info["amount_balance"] < 0: raise UserError( _( - "Total amount in KPI %(name)s will result in {:,.2f}", - name=line.name, - ).format(budget_info["amount_balance"]) + "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") @@ -267,7 +263,7 @@ def action_confirm_state(self): @api.depends("allocated_amount") def _compute_allocated_released_amount(self): for rec in self: - rec.released_amount = rec.allocated_amount + rec.released_amount = rec.allocated_amount + rec.transferred_amount @api.depends("released_amount", "amount_budget") def _compute_diff_amount(self): @@ -366,10 +362,9 @@ def _check_budget_amount(self): ): raise UserError( _( - "Planning amount should equal " - "to the released amount {:,.2f} %(symbol)s", - symbol=rec.currency_id.symbol, - ).format(rec.released_amount) + "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 ( @@ -383,9 +378,8 @@ def _check_budget_amount(self): raise UserError( _( "Planning amount should be greater than " - "initial balance {:,.2f} %(symbol)s", - symbol=rec.currency_id.symbol, - ).format(rec.amount_initial) + "initial balance {amount:,.2f} {symbol}" + ).format(amount=rec.amount_initial, symbol=rec.currency_id.symbol) ) def action_draft(self): @@ -551,9 +545,6 @@ class BudgetControlLine(models.Model): analytic_account_id = fields.Many2one( comodel_name="account.analytic.account", string="Analytic account" ) - analytic_tag_ids = fields.Many2many( - comodel_name="account.analytic.tag", string="Analytic Tags" - ) amount = fields.Float() template_line_id = fields.Many2one( comodel_name="budget.template.line", diff --git a/budget_control/models/budget_move_adjustment.py b/budget_control/models/budget_move_adjustment.py index 722718e5..71f83518 100644 --- a/budget_control/models/budget_move_adjustment.py +++ b/budget_control/models/budget_move_adjustment.py @@ -56,14 +56,16 @@ class BudgetMoveAdjustment(models.Model): tracking=True, ) - @api.model - def create(self, vals): + @api.model_create_multi + def create(self, vals_list): """Generate a new name using the 'budget.move.adjustment' sequence""" - if vals.get("name", "/") == "/": - vals["name"] = ( - self.env["ir.sequence"].next_by_code("budget.move.adjustment") or "/" - ) - return super().create(vals) + 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.""" @@ -112,6 +114,7 @@ class BudgetMoveAdjustmentItem(models.Model): _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( @@ -148,10 +151,6 @@ class BudgetMoveAdjustmentItem(models.Model): required=True, index=True, ) - analytic_tag_ids = fields.Many2many( - comodel_name="account.analytic.tag", - string="Analytic Tags", - ) currency_id = fields.Many2one( related="adjust_id.currency_id", readonly=True, @@ -178,7 +177,7 @@ def recompute_budget_move(self): item.budget_move_ids.unlink() item.commit_budget() - def _init_docline_budget_vals(self, budget_vals): + 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 @@ -187,10 +186,9 @@ def _init_docline_budget_vals(self, budget_vals): budget_vals.update( { "adjust_item_id": self.id, - "analytic_tag_ids": [(6, 0, self.analytic_tag_ids.ids)], } ) - return super()._init_docline_budget_vals(budget_vals) + 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 index e9864e35..6d9105dc 100644 --- a/budget_control/models/budget_period.py +++ b/budget_control/models/budget_period.py @@ -166,11 +166,40 @@ def check_budget(self, doclines, doc_type="account"): 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 doclines[doclines._budget_analytic_field]: - doclines = doclines.filtered( - lambda l: l[doclines._budget_analytic_field] == aa - ) + 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: @@ -186,7 +215,10 @@ def check_budget(self, doclines, doc_type="account"): if not controls: return # The budget_control of these analytics must be active - analytic_ids = [x["analytic_id"] for x in controls] + 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) @@ -213,8 +245,8 @@ def check_budget_precommit(self, doclines, doc_type="account"): if not doclines: return doclines = doclines.sudo() - budget_moves_uncommit = False # 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 @@ -381,7 +413,6 @@ def _get_budget_monitor_report(self): return self.env["budget.monitor.report"] def _get_budget_avaiable(self, analytic_id, template_lines): - self.flush() self._cr.execute( sql.SQL( """SELECT * FROM ({monitoring}) report @@ -438,7 +469,7 @@ def _check_budget_available(self, controls, budget_period): balance_currency = self._get_balance_currency( company, balance, doc_currency, date_commit ) - formatted_balance = format_amount( + fomatted_balance = format_amount( self.env, balance_currency, doc_currency ) analytic_name = Analytic.browse(analytic_id).display_name @@ -447,10 +478,8 @@ def _check_budget_available(self, controls, budget_period): template_lines.display_name, analytic_name ) warnings.append( - _( - "%(analytic_name)s, will result in %(formatted_balance)s", - analytic_name=analytic_name, - formatted_balance=formatted_balance, + _("{analytic_name}, will result in {formatted_balance}").format( + analytic_name=analytic_name, formatted_balance=fomatted_balance ) ) return list(set(warnings)) diff --git a/budget_control/models/budget_transfer.py b/budget_control/models/budget_transfer.py index 4217e8a8..ed0c56cd 100644 --- a/budget_control/models/budget_transfer.py +++ b/budget_control/models/budget_transfer.py @@ -44,13 +44,14 @@ class BudgetTransfer(models.Model): tracking=True, ) - @api.model - def create(self, vals): - if vals.get("name", "/") == "/": - vals["name"] = ( - self.env["ir.sequence"].next_by_code("budget.transfer") or "/" - ) - return super().create(vals) + @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.""" diff --git a/budget_control/readme/DESCRIPTION.rst b/budget_control/readme/DESCRIPTION.rst index 55da95ec..e1094a19 100644 --- a/budget_control/readme/DESCRIPTION.rst +++ b/budget_control/readme/DESCRIPTION.rst @@ -19,16 +19,11 @@ Budget Control Core Features: for approved expense. Note that, in this budget_control module, there is no extension for budget commitment yet. -* **Budget KPI (budget.kpi)** - - Budget KPI is used to measure the efficiency of planning compared to actual usage. - It is linked to Account Codes, and one Budget KPI can be associated with more than one account code. - * **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 budget kpi and accounting, + The budget template has a relationship with the accounting, and is used to control spending based on pre-configured accounts. * **Budget Period (budget.period)** @@ -93,11 +88,10 @@ Following are brief explanation of what the extended module will do. These modules extend base.budget.move for other document budget commitment. -* budget_control_advance_clearing -* budget_control_contract * budget_control_expense * budget_control_purchase * budget_control_purchase_request +* budget_control_sale **Budget Allocation** @@ -106,11 +100,6 @@ until set budget control. and allow create Master Data source of fund, analytic Users can view source of fund monitoring report * budget_allocation -* budget_allocation_advance_clearing -* budget_allocation_contract -* budget_allocation_expense -* budget_allocation_purchase -* budget_allocation_purchase_request **Tier Validation** @@ -128,3 +117,7 @@ we can use dimension to create persistent dimension columns 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 index 58757c80..8090f484 100644 --- a/budget_control/readme/USAGE.rst +++ b/budget_control/readme/USAGE.rst @@ -1,13 +1,12 @@ Before start using this module, following access right must be set. - - - Budget User for Budget Control Sheet, Budget Report - - Budget Manager for Budget Period + - 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 + To create budget KPI using in budget template 2. Create new Budget Template @@ -22,29 +21,26 @@ Followings are sample steps to start with, 4. Create Budget Control Sheet - To create budget control sheet, you can create by using the helper, + 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 Group - - Check All Analytic Accounts, this will list all analytic account in selected groups + - 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 "Generate Budget Control Sheet", and then view the newly created control sheets. + - 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" or by Menu > Budgeting > Budget Control Sheet, to open them. + icon "Budget Control Sheets" or by Menu > Budgeting > Budget Control Sheet, to open them. - - Within the "Plan Date Range" period, the Plan table displays all KPIs split by Plan Date Range - - If you need to edit the plan, click the "Reset Options" tab, then select the KPIs you want to plan - - Click the "Soft Reset" button to generate KPIs. The amounts in the plan table will not disappear. - - Click the "Hard Reset" button to generate KPIs. The amounts in the plan table will disappear. + - Based on "Plan Date Range" period, Plan table will show all KPI split by Plan Date Range - Allocate budget amount as appropriate. - - Click Submit > Control, state will change to Controlled. + - 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 "Soft Reset" or "Hard Reset" anytime. + Once ready, you can click on "Reset Plan" anytime. 6. Budget Reports diff --git a/budget_control/report/budget_monitor_report.py b/budget_control/report/budget_monitor_report.py index b6dd4230..c63aa625 100644 --- a/budget_control/report/budget_monitor_report.py +++ b/budget_control/report/budget_monitor_report.py @@ -25,8 +25,8 @@ class BudgetMonitorReport(models.Model): analytic_account_id = fields.Many2one( comodel_name="account.analytic.account", ) - analytic_group = fields.Many2one( - comodel_name="account.analytic.group", + analytic_plan = fields.Many2one( + comodel_name="account.analytic.plan", ) date = fields.Date() amount = fields.Float() @@ -99,7 +99,7 @@ def _get_select_amount_types(self): '%s,' || a.%s as res_id, a.kpi_id, a.analytic_account_id, - a.analytic_group, + a.analytic_plan, a.date as date, '%s' as amount_type, a.credit-a.debit as amount, @@ -141,7 +141,7 @@ def _select_budget(self): 'budget.control.line,' || a.id as res_id, a.kpi_id, a.analytic_account_id, - b.analytic_group, + b.analytic_plan, a.date_to as date, -- approx date '1_budget' as amount_type, a.amount as amount, diff --git a/budget_control/report/budget_monitor_report_view.xml b/budget_control/report/budget_monitor_report_view.xml index 8b79481b..eeef1f5b 100644 --- a/budget_control/report/budget_monitor_report_view.xml +++ b/budget_control/report/budget_monitor_report_view.xml @@ -81,9 +81,9 @@ context="{'group_by':'budget_period_id'}" /> - - + @@ -46,9 +45,9 @@ /> @@ -9,10 +8,11 @@ /* :Author: David Goodger (goodger@python.org) -:Id: $Id: html4css1.css 8954 2022-01-20 10:10:25Z milde $ +:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $ :Copyright: This stylesheet has been placed in the public domain. Default cascading style sheet for the HTML output of Docutils. +Despite the name, some widely supported CSS2 features are used. See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to customize this style sheet. @@ -275,7 +275,7 @@ margin-left: 2em ; margin-right: 2em } -pre.code .ln { color: grey; } /* line numbers */ +pre.code .ln { color: gray; } /* line numbers */ pre.code, code { background-color: #eeeeee } pre.code .comment, code .comment { color: #5C6576 } pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold } @@ -301,7 +301,7 @@ span.pre { white-space: pre } -span.problematic { +span.problematic, pre.problematic { color: red } span.section-subtitle { @@ -367,9 +367,9 @@

Budget Control

!! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:170b7aa450e2ccdfa27c0d5840cf49a8511a46198988caa073e23f03a6689384 +!! source digest: sha256:353c8401879120bf194a5135e6f67838a807a00dc09ec0d8388ce36d90910040 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> -

Alpha License: AGPL-3 ecosoft-odoo/budgeting

+

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 @@ -389,14 +389,10 @@

Budget Control Core Features:

for approved expense. Note that, in this budget_control module, there is no extension for budget commitment yet.

-
  • Budget KPI (budget.kpi)

    -

    Budget KPI is used to measure the efficiency of planning compared to actual usage. -It is linked to Account Codes, and one Budget KPI can be associated with more than one account code.

    -
  • 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 budget kpi and accounting, +The budget template has a relationship with the accounting, and is used to control spending based on pre-configured accounts.

  • Budget Period (budget.period)

    @@ -453,11 +449,10 @@

    Extended Modules:

    Budget Move extension

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

      -
    • budget_control_advance_clearing
    • -
    • budget_control_contract
    • 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) @@ -465,11 +460,6 @@

    Extended Modules:

    Users can view source of fund monitoring report

    • budget_allocation
    • -
    • budget_allocation_advance_clearing
    • -
    • budget_allocation_contract
    • -
    • budget_allocation_expense
    • -
    • budget_allocation_purchase
    • -
    • budget_allocation_purchase_request

    Tier Validation

    Extend base_tier_validation for budget control sheet

    @@ -485,6 +475,11 @@

    Extended Modules:

    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. @@ -501,19 +496,18 @@

    Extended Modules:

    Usage

    -

    Before start using this module, following access right must be set.

    -
    -
      +
      +
      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
      • -
      +

      To create budget KPI using in budget template

    2. Create new Budget Template

        @@ -531,33 +525,30 @@

        Usage

      • Create Budget Control Sheet

        -

        To create budget control sheet, you can create by using the helper, +

        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 Group
        • -
        • Check All Analytic Accounts, this will list all analytic account in selected groups
        • +
        • 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 “Generate Budget Control Sheet”, and then view the newly created control sheets.
        • +
        • Click “Create Budget Control Sheet”, and then view the newly created control sheets.
      • Allocate amount in Budget Control Sheets

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

        +icon “Budget Control Sheets” or by Menu > Budgeting > Budget Control Sheet, to open them.

          -
        • Within the “Plan Date Range” period, the Plan table displays all KPIs split by Plan Date Range
        • -
        • If you need to edit the plan, click the “Reset Options” tab, then select the KPIs you want to plan
        • -
        • Click the “Soft Reset” button to generate KPIs. The amounts in the plan table will not disappear.
        • -
        • Click the “Hard Reset” button to generate KPIs. The amounts in the plan table will disappear.
        • +
        • Based on “Plan Date Range” period, Plan table will show all KPI split by Plan Date Range
        • Allocate budget amount as appropriate.
        • -
        • Click Submit > Control, state will change to Controlled.
        • +
        • 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 “Soft Reset” or “Hard Reset” anytime.

        +Once ready, you can click on “Reset Plan” anytime.

      • Budget Reports

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

        @@ -579,7 +570,7 @@

        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.

        +feedback.

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

    @@ -601,9 +592,9 @@

    Contributors

    Maintainers

    -

    Current maintainer:

    -

    kittiu

    -

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

    +

    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 index 1b9fdadf..9dfbd9e1 100644 --- a/budget_control/static/src/xml/budget_popover.xml +++ b/budget_control/static/src/xml/budget_popover.xml @@ -1,37 +1,38 @@ -
    -

    - -

    - - - - - - - - - - - - - - - - -
    - Planned - - -
    - Used - - - -
    - Available - - = -
    -
    + + +
    + +
    + + + + + + + + + + + + + + + +
    + Planned + + +
    + Used + + - +
    + Available + + = +
    +
    +
    diff --git a/budget_control/tests/common.py b/budget_control/tests/common.py index 6c4fb3a0..079bd0e6 100644 --- a/budget_control/tests/common.py +++ b/budget_control/tests/common.py @@ -5,7 +5,8 @@ from dateutil.rrule import MONTHLY -from odoo.tests.common import Form, TransactionCase +from odoo import Command +from odoo.tests.common import TransactionCase class BudgetControlCommon(TransactionCase): @@ -14,59 +15,62 @@ def setUpClass(cls): super().setUpClass() cls.env.company.budget_include_tax = False # Not Tax Included cls.year = datetime.now().year - RangeType = cls.env["date.range.type"] + 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"] - Partner = cls.env["res.partner"] - # Vendor - cls.vendor = Partner.create({"name": "Sample Vendor"}) + 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 = RangeType.create({"name": "TestQuarter"}) + cls.date_range_type = cls.RangeType.create({"name": "TestQuarter"}) cls._create_date_range_quarter(cls) # Setup some required entity - Account = cls.env["account.account"] - type_exp = cls.env.ref("account.data_account_type_expenses").id - type_adv = cls.env.ref("account.data_account_type_current_assets").id - cls.account_kpi1 = Account.create( - {"name": "KPI1", "code": "KPI1", "user_type_id": type_exp} + cls.account_kpi1 = cls.Account.create( + {"name": "KPI1", "code": "KPI1", "account_type": "expense"} ) - cls.account_kpi2 = Account.create( - {"name": "KPI2", "code": "KPI2", "user_type_id": type_exp} + cls.account_kpi2 = cls.Account.create( + {"name": "KPI2", "code": "KPI2", "account_type": "expense"} ) - cls.account_kpi3 = Account.create( - {"name": "KPI3", "code": "KPI3", "user_type_id": type_exp} + cls.account_kpi3 = cls.Account.create( + {"name": "KPI3", "code": "KPI3", "account_type": "expense"} ) # Create an extra account, but not in control - cls.account_kpiX = Account.create( - {"name": "KPIX", "code": "KPIX", "user_type_id": type_exp} + cls.account_kpiX = cls.Account.create( + {"name": "KPIX", "code": "KPIX", "account_type": "expense"} ) # Create an extra account, for advance - cls.account_kpiAV = Account.create( + cls.account_kpiAV = cls.Account.create( { "name": "KPIAV", "code": "KPIAV", - "user_type_id": type_adv, + "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"}) - Product = cls.env["product.product"] - cls.product1 = Product.create( + + cls.product1 = cls.Product.create( { "name": "Product 1", "property_account_expense_id": cls.account_kpi1.id, } ) - cls.product2 = Product.create( + cls.product2 = cls.Product.create( { "name": "Product 2", "property_account_expense_id": cls.account_kpi2.id, } ) - cls.template = cls.env["budget.template"].create({"name": "Test KPI"}) + cls.template = cls.BudgetTemplate.create({"name": "Test KPI"}) # Create budget kpis cls._create_budget_template_kpi(cls) @@ -74,10 +78,16 @@ def setUpClass(cls): 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"}) - cls.costcenterX = cls.Analytic.create({"name": "CostCenterX"}) + 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"] @@ -131,35 +141,45 @@ def _create_budget_period_fy(self, template_id, date_range_type_id): ) return budget_period - def _create_invoice(self, inv_type, vendor, invoice_date, analytic, invoice_lines): - Invoice = self.env["account.move"] - with Form( - Invoice.with_context(default_move_type=inv_type), - view="account.view_move_form", - ) as inv: - inv.partner_id = vendor - inv.invoice_date = invoice_date - for il in invoice_lines: - with inv.invoice_line_ids.new() as line: - line.quantity = 1 - line.account_id = il.get("account") - line.price_unit = il.get("price_unit") - line.analytic_account_id = analytic - invoice = inv.save() + 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, account, amount): - Invoice = self.env["account.move"] - view_id = "account.view_move_form" - with Form( - Invoice.with_context(default_move_type="in_invoice"), view=view_id - ) as inv: - inv.partner_id = self.vendor - inv.invoice_date = datetime.today() - with inv.invoice_line_ids.new() as line: - line.quantity = 1 - line.account_id = account - line.price_unit = amount - line.analytic_account_id = analytic - invoice = inv.save() + 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 index 67c2f708..2af735b7 100644 --- a/budget_control/tests/test_budget_control.py +++ b/budget_control/tests/test_budget_control.py @@ -56,26 +56,33 @@ def test_01_no_budget_control_check(self): """ self.budget_period.control_budget = True # KPI not in control -> lock - bill1 = self._create_simple_bill(self.costcenter1, self.account_kpiX, 100) + 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(self.costcenter1, self.account_kpi1, 100000) + 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(self.costcenter1, self.account_kpi1, 100000) + 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(self.costcenter1, self.account_kpi1, 100000) + bill4 = self._create_simple_bill( + analytic_distribution, self.account_kpi1, 100000 + ) bill4.action_post() self.assertTrue(bill4.budget_move_ids) @@ -88,7 +95,8 @@ def test_02_budget_control_not_confirmed(self): invoice raise warning """ self.budget_period.control_budget = True - bill1 = self._create_simple_bill(self.costcenter1, self.account_kpi1, 400) + 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() @@ -114,11 +122,12 @@ def test_03_control_level_analytic_kpi(self): """ 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(self.costcenter1, self.account_kpi1, 401) + bill1 = self._create_simple_bill(analytic_distribution, self.account_kpi1, 401) with self.assertRaises(UserError): bill1.action_post() @@ -130,11 +139,12 @@ def test_04_control_level_analytic(self): """ 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(self.costcenter1, self.account_kpi1, 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) @@ -144,11 +154,14 @@ 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(self.costcenter1, self.account_kpi1, 100000) + bill1 = self._create_simple_bill( + analytic_distribution, self.account_kpi1, 100000 + ) bill1.action_post() @freeze_time("2001-02-01") @@ -157,10 +170,15 @@ def test_06_refund_no_budget_check(self): # 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(self.costcenter1, self.account_kpi1, 100000) + 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 @@ -169,10 +187,12 @@ def test_06_refund_no_budget_check(self): "in_refund", self.vendor, datetime.today(), - self.costcenter1, - [{"account": self.account_kpi1, "price_unit": 100}], + 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") @@ -187,9 +207,10 @@ def test_07_auto_date_commit(self): # 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(self.costcenterX, self.account_kpiX, 10) + 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, @@ -204,7 +225,7 @@ def test_07_auto_date_commit(self): 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(self.costcenterX, self.account_kpi1, 10) + 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, @@ -225,12 +246,13 @@ 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(self.costcenterX, self.account_kpiX, 10) + 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. @@ -244,11 +266,14 @@ 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(self.costcenter1, self.account_kpi1, 100000) + 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): @@ -256,9 +281,10 @@ 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(self.costcenterX, self.account_kpiX, 10) + 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. 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_move_views.xml b/budget_control/views/account_move_views.xml index 728108ff..910b0370 100644 --- a/budget_control/views/account_move_views.xml +++ b/budget_control/views/account_move_views.xml @@ -28,20 +28,6 @@ - - account.move.line.tree.grouped - account.move.line - - - - - - - account.move.form @@ -57,7 +43,7 @@ /> @@ -83,13 +68,13 @@ /> show @@ -119,7 +103,7 @@ name="budget_commit" attrs="{'invisible': [('budget_move_ids', '=', [])]}" > -
    +
    + - + +