From f96f103b5af626ad5384a59c807d1aac9db1a99c Mon Sep 17 00:00:00 2001 From: Marcel Savegnago Date: Wed, 15 Jul 2026 13:36:29 -0300 Subject: [PATCH 1/2] [ADD] project_purchase_budget: add new module --- project_purchase_budget/README.rst | 141 +++++ project_purchase_budget/__init__.py | 1 + project_purchase_budget/__manifest__.py | 29 ++ .../data/tier_definition_data.xml | 18 + project_purchase_budget/models/__init__.py | 2 + .../models/project_project.py | 194 +++++++ .../models/purchase_order.py | 137 +++++ project_purchase_budget/readme/CONFIGURE.rst | 18 + .../readme/CONTRIBUTORS.rst | 3 + .../readme/DESCRIPTION.rst | 11 + project_purchase_budget/readme/INSTALL.rst | 11 + project_purchase_budget/readme/USAGE.rst | 16 + .../project_purchase_budget_security.xml | 18 + .../static/description/index.html | 492 ++++++++++++++++++ project_purchase_budget/tests/__init__.py | 1 + .../tests/test_project_purchase_budget.py | 184 +++++++ .../views/project_project_views.xml | 52 ++ .../views/purchase_order_views.xml | 30 ++ .../odoo/addons/project_purchase_budget | 1 + setup/project_purchase_budget/setup.py | 6 + 20 files changed, 1365 insertions(+) create mode 100644 project_purchase_budget/README.rst create mode 100644 project_purchase_budget/__init__.py create mode 100644 project_purchase_budget/__manifest__.py create mode 100644 project_purchase_budget/data/tier_definition_data.xml create mode 100644 project_purchase_budget/models/__init__.py create mode 100644 project_purchase_budget/models/project_project.py create mode 100644 project_purchase_budget/models/purchase_order.py create mode 100644 project_purchase_budget/readme/CONFIGURE.rst create mode 100644 project_purchase_budget/readme/CONTRIBUTORS.rst create mode 100644 project_purchase_budget/readme/DESCRIPTION.rst create mode 100644 project_purchase_budget/readme/INSTALL.rst create mode 100644 project_purchase_budget/readme/USAGE.rst create mode 100644 project_purchase_budget/security/project_purchase_budget_security.xml create mode 100644 project_purchase_budget/static/description/index.html create mode 100644 project_purchase_budget/tests/__init__.py create mode 100644 project_purchase_budget/tests/test_project_purchase_budget.py create mode 100644 project_purchase_budget/views/project_project_views.xml create mode 100644 project_purchase_budget/views/purchase_order_views.xml create mode 120000 setup/project_purchase_budget/odoo/addons/project_purchase_budget create mode 100644 setup/project_purchase_budget/setup.py diff --git a/project_purchase_budget/README.rst b/project_purchase_budget/README.rst new file mode 100644 index 0000000..2f4111c --- /dev/null +++ b/project_purchase_budget/README.rst @@ -0,0 +1,141 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +======================= +Project Purchase Budget +======================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:fa9c797a5345ddf0633ea04468fbb23ad53a5116e0140a8626450561cabba5c4 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-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-Escodoo%2Fproject--addons-lightgray.png?logo=github + :target: https://github.com/Escodoo/project-addons/tree/16.0/project_purchase_budget + :alt: Escodoo/project-addons + +|badge1| |badge2| |badge3| + +Limit purchases per project to a manager-defined percentage of the sale +value. Purchase orders that would exceed the budget require tier +validation approval. + +The module computes the purchase budget as sale amount × percentage, +tracks committed spend from confirmed purchase orders (via analytic +distribution), and exposes project indicators: budget, spent, remaining, +consumed % and project contribution (sale − spent). + +Note: Spend is based on confirmed purchase orders, not vendor bills. + + +**Table of contents** + +.. contents:: + :local: + +Installation +============ + +To install this module, you need to: + +#. Ensure the following modules are available and installable: + + * ``project``, ``purchase``, ``sale_project``, ``project_purchase`` + * ``base_tier_validation``, ``base_tier_validation_formula`` + * ``purchase_tier_validation`` + +#. Install ``project_purchase_budget``. + +#. If the module was already present, upgrade it from Apps. + +Configuration +============= + +To configure this module, you need to: + +#. Enable **Analytic Accounting** (Settings → Accounting). +#. Assign users who may set the percentage and approve over-budget + purchase orders to the group **Project Purchase Budget Manager** + (project managers get this group automatically via implication). +#. Open *Settings → Technical → Tier Validations → Tier Definition* and + confirm the record **Purchase exceeds project budget** exists: + + * Model: Purchase Order + * Definition type: Formula + * Expression: ``rec.exceeds_project_purchase_budget`` + * Reviewer group: Project Purchase Budget Manager + +#. Optionally add a second tier definition (higher sequence) for a + superior approval group if two approval levels are required. + + + +Usage +===== + +To use this module, you need to: + +* Create or open a project with an analytic account. +* Link a confirmed sales order (billable project + sales order item) + **or** set **Sale Amount Override** on the project. +* Set **Purchase Budget %** (e.g. ``60`` for 60%). +* Create purchase orders with analytic distribution on the project + analytic account. +* If the purchase order would exceed the budget, a warning appears and + tier validation must be requested/approved before confirmation. +* On the project **Purchase Budget** tab, review: + + * Purchase Budget (planned) + * Purchase Spent (actual / committed) + * Purchase Remaining and Consumed % + * Project Contribution (sale − spent) + +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 +~~~~~~~ + +* Escodoo + +Contributors +~~~~~~~~~~~~ + +* `Escodoo `_: + + * Marcel Savegnago + +Maintainers +~~~~~~~~~~~ + +.. |maintainer-marcelsavegnago| image:: https://github.com/marcelsavegnago.png?size=40px + :target: https://github.com/marcelsavegnago + :alt: marcelsavegnago + +Current maintainer: + +|maintainer-marcelsavegnago| + +This module is part of the `Escodoo/project-addons `_ project on GitHub. + +You are welcome to contribute. diff --git a/project_purchase_budget/__init__.py b/project_purchase_budget/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/project_purchase_budget/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/project_purchase_budget/__manifest__.py b/project_purchase_budget/__manifest__.py new file mode 100644 index 0000000..afa7ccc --- /dev/null +++ b/project_purchase_budget/__manifest__.py @@ -0,0 +1,29 @@ +# Copyright 2026 Escodoo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Project Purchase Budget", + "summary": "Limit project purchases to a percentage of the sale value " + "with tier validation when the budget is exceeded", + "version": "16.0.1.0.0", + "category": "Purchases", + "author": "Escodoo", + "website": "https://github.com/Escodoo/project-addons", + "license": "AGPL-3", + "depends": [ + "project_purchase", + "sale_project", + "purchase_tier_validation", + "base_tier_validation_formula", + ], + "data": [ + "security/project_purchase_budget_security.xml", + "data/tier_definition_data.xml", + "views/project_project_views.xml", + "views/purchase_order_views.xml", + ], + "demo": [], + "installable": True, + "application": False, + "development_status": "Beta", + "maintainers": ["marcelsavegnago"], +} diff --git a/project_purchase_budget/data/tier_definition_data.xml b/project_purchase_budget/data/tier_definition_data.xml new file mode 100644 index 0000000..a1cbd00 --- /dev/null +++ b/project_purchase_budget/data/tier_definition_data.xml @@ -0,0 +1,18 @@ + + + + Purchase exceeds project budget + + group + + formula + rec.exceeds_project_purchase_budget + 10 + + True + True + + diff --git a/project_purchase_budget/models/__init__.py b/project_purchase_budget/models/__init__.py new file mode 100644 index 0000000..e818e39 --- /dev/null +++ b/project_purchase_budget/models/__init__.py @@ -0,0 +1,2 @@ +from . import project_project +from . import purchase_order diff --git a/project_purchase_budget/models/project_project.py b/project_purchase_budget/models/project_project.py new file mode 100644 index 0000000..79739fd --- /dev/null +++ b/project_purchase_budget/models/project_project.py @@ -0,0 +1,194 @@ +# Copyright 2026 - TODAY, Marcel Savegnago +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import date + +from odoo import api, fields, models +from odoo.tools.float_utils import float_is_zero, float_round + + +class ProjectProject(models.Model): + _inherit = "project.project" + + purchase_budget_percent = fields.Float( + string="Purchase Budget %", + help="Maximum percentage of the sale value that can be spent on " + "purchases for this project. Set by the project manager.", + digits=(16, 2), + tracking=True, + ) + purchase_budget_sale_override = fields.Monetary( + string="Sale Amount Override", + currency_field="currency_id", + help="Optional manual sale amount used instead of linked sales " + "orders when computing the purchase budget.", + tracking=True, + ) + purchase_budget_sale_amount = fields.Monetary( + string="Sale Amount", + compute="_compute_purchase_budget_kpis", + currency_field="currency_id", + help="Sale value used as the base for the purchase budget " + "(linked sales orders, or the override when set).", + ) + purchase_budget_amount = fields.Monetary( + string="Purchase Budget", + compute="_compute_purchase_budget_kpis", + currency_field="currency_id", + help="Planned purchase budget: sale amount × purchase budget %.", + ) + purchase_spent_amount = fields.Monetary( + string="Purchase Spent", + compute="_compute_purchase_budget_kpis", + currency_field="currency_id", + help="Committed purchase spend from confirmed purchase orders " + "allocated to this project via analytic distribution.", + ) + purchase_remaining_amount = fields.Monetary( + string="Purchase Remaining", + compute="_compute_purchase_budget_kpis", + currency_field="currency_id", + ) + purchase_consumed_percent = fields.Float( + string="Purchase Consumed %", + compute="_compute_purchase_budget_kpis", + digits=(16, 2), + ) + project_contribution_amount = fields.Monetary( + string="Project Contribution", + compute="_compute_purchase_budget_kpis", + currency_field="currency_id", + help="Sale amount minus committed purchase spend " + "(contribution after purchases).", + ) + is_purchase_budget_manager = fields.Boolean( + compute="_compute_is_purchase_budget_manager", + ) + + def _compute_is_purchase_budget_manager(self): + is_manager = self.env.user.has_group( + "project_purchase_budget.group_purchase_budget_manager" + ) + for project in self: + project.is_purchase_budget_manager = is_manager + + def _get_purchase_budget_sale_orders(self): + """Return confirmed sales orders linked to this project.""" + self.ensure_one() + orders = self._get_sale_orders() + if self.sale_order_id: + orders |= self.sale_order_id + return orders.filtered(lambda so: so.state in ("sale", "done")) + + def _convert_to_project_currency(self, amount, from_currency, company=None): + """Convert amount from another currency to the project currency.""" + self.ensure_one() + company = company or self.company_id or self.env.company + to_currency = self.currency_id + if not from_currency or from_currency == to_currency: + return amount + return from_currency._convert( + amount, + to_currency, + company, + date.today(), + ) + + def _get_confirmed_purchase_spent(self, exclude_order_ids=None): + """Sum confirmed PO line subtotals allocated to this project's analytic.""" + self.ensure_one() + if not self.analytic_account_id: + return 0.0 + exclude_order_ids = exclude_order_ids or [] + analytic_id = str(self.analytic_account_id.id) + domain = [("state", "in", ("purchase", "done"))] + if exclude_order_ids: + domain.append(("order_id", "not in", exclude_order_ids)) + query = self.env["purchase.order.line"].sudo()._search(domain) + query.add_where( + "purchase_order_line.analytic_distribution ? %s", + [analytic_id], + ) + query_string, query_param = query.select( + '"purchase_order_line".id', + "price_subtotal", + "purchase_order_line.currency_id", + "purchase_order_line.company_id", + '"purchase_order_line".analytic_distribution', + ) + self._cr.execute(query_string, query_param) + spent = 0.0 + for row in self._cr.dictfetchall(): + distribution = row["analytic_distribution"] or {} + if analytic_id not in distribution: + continue + contribution = distribution[analytic_id] / 100.0 + amount = row["price_subtotal"] * contribution + currency = self.env["res.currency"].browse(row["currency_id"]) + company = self.env["res.company"].browse(row["company_id"]) + spent += self._convert_to_project_currency(amount, currency, company) + return float_round(spent, precision_rounding=self.currency_id.rounding) + + def _get_purchase_budget_sale_base(self): + """Return the sale amount used as budget base (override or linked SOs).""" + self.ensure_one() + if not float_is_zero( + self.purchase_budget_sale_override, + precision_rounding=self.currency_id.rounding, + ): + return self.purchase_budget_sale_override + total = 0.0 + for order in self._get_purchase_budget_sale_orders(): + total += self._convert_to_project_currency( + order.amount_untaxed, + order.currency_id, + order.company_id, + ) + return float_round(total, precision_rounding=self.currency_id.rounding) + + @api.depends( + "purchase_budget_percent", + "purchase_budget_sale_override", + "analytic_account_id", + "currency_id", + "sale_line_id", + "sale_line_id.price_subtotal", + "sale_line_id.order_id.amount_untaxed", + "sale_line_id.order_id.state", + "allow_billable", + ) + def _compute_purchase_budget_kpis(self): + for project in self: + sale_amount = project._get_purchase_budget_sale_base() + percent = project.purchase_budget_percent or 0.0 + budget = float_round( + sale_amount * percent / 100.0, + precision_rounding=project.currency_id.rounding, + ) + spent = project._get_confirmed_purchase_spent() + remaining = float_round( + budget - spent, + precision_rounding=project.currency_id.rounding, + ) + consumed = 0.0 + if not float_is_zero( + budget, precision_rounding=project.currency_id.rounding + ): + consumed = float_round(spent * 100.0 / budget, precision_digits=2) + project.purchase_budget_sale_amount = sale_amount + project.purchase_budget_amount = budget + project.purchase_spent_amount = spent + project.purchase_remaining_amount = remaining + project.purchase_consumed_percent = consumed + project.project_contribution_amount = float_round( + sale_amount - spent, + precision_rounding=project.currency_id.rounding, + ) + + def _budget_control_enabled(self): + """Whether purchase budget control is active for this project.""" + self.ensure_one() + return bool(self.purchase_budget_percent) and not float_is_zero( + self.purchase_budget_percent, + precision_digits=2, + ) diff --git a/project_purchase_budget/models/purchase_order.py b/project_purchase_budget/models/purchase_order.py new file mode 100644 index 0000000..5256463 --- /dev/null +++ b/project_purchase_budget/models/purchase_order.py @@ -0,0 +1,137 @@ +# Copyright 2026 - TODAY, Marcel Savegnago +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models +from odoo.tools.float_utils import float_compare + + +class PurchaseOrder(models.Model): + _inherit = "purchase.order" + + exceeds_project_purchase_budget = fields.Boolean( + compute="_compute_exceeds_project_purchase_budget", + copy=False, + help="True when confirming this order would make at least one " + "linked project exceed its purchase budget percentage. " + "Never copied on duplicate; always recomputed from current budget.", + ) + + def _get_project_amounts_from_order(self): + """Map project -> amount from this PO allocated via analytic distribution.""" + self.ensure_one() + Project = self.env["project.project"] + amounts = {} + analytic_ids = set() + for line in self.order_line: + distribution = line.analytic_distribution or {} + analytic_ids.update(int(account_id) for account_id in distribution) + if not analytic_ids: + return amounts + projects = Project.search([("analytic_account_id", "in", list(analytic_ids))]) + project_by_analytic = { + project.analytic_account_id.id: project for project in projects + } + for line in self.order_line: + distribution = line.analytic_distribution or {} + for account_id_str, percent in distribution.items(): + account_id = int(account_id_str) + project = project_by_analytic.get(account_id) + if not project: + continue + contribution = percent / 100.0 + amount = line.price_subtotal * contribution + amount = project._convert_to_project_currency( + amount, + line.currency_id, + line.company_id, + ) + amounts[project.id] = amounts.get(project.id, 0.0) + amount + return amounts + + def _order_exceeds_any_project_budget(self): + """Return True if this PO would push any controlled project over budget.""" + self.ensure_one() + Project = self.env["project.project"] + project_amounts = self._get_project_amounts_from_order() + if not project_amounts: + return False + projects = Project.browse(list(project_amounts.keys())) + for project in projects: + if not project._budget_control_enabled(): + continue + budget = project.purchase_budget_amount + # Exclude this order from spent so draft/reconfirm is compared fairly + spent = project._get_confirmed_purchase_spent( + exclude_order_ids=self.ids, + ) + projected = spent + project_amounts[project.id] + if ( + float_compare( + projected, + budget, + precision_rounding=project.currency_id.rounding, + ) + > 0 + ): + return True + return False + + def _compute_exceeds_project_purchase_budget(self): + # No @api.depends: always recompute on access so project % changes + # are reflected without stale cache on draft POs. + for order in self: + order.exceeds_project_purchase_budget = ( + order._order_exceeds_any_project_budget() + ) + + def copy_data(self, default=None): + """Ensure budget exceed flag is never copied to a duplicated PO.""" + data_list = super().copy_data(default=default) + for data in data_list: + # Computed with copy=False, but strip explicitly so defaults / + # future store=True cannot carry over the source order's signal. + data.pop("exceeds_project_purchase_budget", None) + return data_list + + def write(self, vals): + projects_before = self._get_affected_budget_projects() + res = super().write(vals) + projects_after = self._get_affected_budget_projects() + (projects_before | projects_after).invalidate_recordset( + [ + "purchase_budget_sale_amount", + "purchase_budget_amount", + "purchase_spent_amount", + "purchase_remaining_amount", + "purchase_consumed_percent", + "project_contribution_amount", + ] + ) + return res + + @api.model_create_multi + def create(self, vals_list): + orders = super().create(vals_list) + orders._get_affected_budget_projects().invalidate_recordset( + [ + "purchase_budget_sale_amount", + "purchase_budget_amount", + "purchase_spent_amount", + "purchase_remaining_amount", + "purchase_consumed_percent", + "project_contribution_amount", + ] + ) + return orders + + def _get_affected_budget_projects(self): + """Projects impacted by analytic distribution on these orders.""" + analytic_ids = set() + for line in self.mapped("order_line"): + distribution = line.analytic_distribution or {} + analytic_ids.update(int(account_id) for account_id in distribution) + if not analytic_ids: + return self.env["project.project"] + return self.env["project.project"].search( + [("analytic_account_id", "in", list(analytic_ids))] + ) diff --git a/project_purchase_budget/readme/CONFIGURE.rst b/project_purchase_budget/readme/CONFIGURE.rst new file mode 100644 index 0000000..cb0209f --- /dev/null +++ b/project_purchase_budget/readme/CONFIGURE.rst @@ -0,0 +1,18 @@ +To configure this module, you need to: + +#. Enable **Analytic Accounting** (Settings → Accounting). +#. Assign users who may set the percentage and approve over-budget + purchase orders to the group **Project Purchase Budget Manager** + (project managers get this group automatically via implication). +#. Open *Settings → Technical → Tier Validations → Tier Definition* and + confirm the record **Purchase exceeds project budget** exists: + + * Model: Purchase Order + * Definition type: Formula + * Expression: ``rec.exceeds_project_purchase_budget`` + * Reviewer group: Project Purchase Budget Manager + +#. Optionally add a second tier definition (higher sequence) for a + superior approval group if two approval levels are required. + + diff --git a/project_purchase_budget/readme/CONTRIBUTORS.rst b/project_purchase_budget/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000..ae453a6 --- /dev/null +++ b/project_purchase_budget/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `Escodoo `_: + + * Marcel Savegnago diff --git a/project_purchase_budget/readme/DESCRIPTION.rst b/project_purchase_budget/readme/DESCRIPTION.rst new file mode 100644 index 0000000..9e48b9a --- /dev/null +++ b/project_purchase_budget/readme/DESCRIPTION.rst @@ -0,0 +1,11 @@ +Limit purchases per project to a manager-defined percentage of the sale +value. Purchase orders that would exceed the budget require tier +validation approval. + +The module computes the purchase budget as sale amount × percentage, +tracks committed spend from confirmed purchase orders (via analytic +distribution), and exposes project indicators: budget, spent, remaining, +consumed % and project contribution (sale − spent). + +Note: Spend is based on confirmed purchase orders, not vendor bills. + diff --git a/project_purchase_budget/readme/INSTALL.rst b/project_purchase_budget/readme/INSTALL.rst new file mode 100644 index 0000000..eb7608d --- /dev/null +++ b/project_purchase_budget/readme/INSTALL.rst @@ -0,0 +1,11 @@ +To install this module, you need to: + +#. Ensure the following modules are available and installable: + + * ``project``, ``purchase``, ``sale_project``, ``project_purchase`` + * ``base_tier_validation``, ``base_tier_validation_formula`` + * ``purchase_tier_validation`` + +#. Install ``project_purchase_budget``. + +#. If the module was already present, upgrade it from Apps. diff --git a/project_purchase_budget/readme/USAGE.rst b/project_purchase_budget/readme/USAGE.rst new file mode 100644 index 0000000..7581de4 --- /dev/null +++ b/project_purchase_budget/readme/USAGE.rst @@ -0,0 +1,16 @@ +To use this module, you need to: + +* Create or open a project with an analytic account. +* Link a confirmed sales order (billable project + sales order item) + **or** set **Sale Amount Override** on the project. +* Set **Purchase Budget %** (e.g. ``60`` for 60%). +* Create purchase orders with analytic distribution on the project + analytic account. +* If the purchase order would exceed the budget, a warning appears and + tier validation must be requested/approved before confirmation. +* On the project **Purchase Budget** tab, review: + + * Purchase Budget (planned) + * Purchase Spent (actual / committed) + * Purchase Remaining and Consumed % + * Project Contribution (sale − spent) diff --git a/project_purchase_budget/security/project_purchase_budget_security.xml b/project_purchase_budget/security/project_purchase_budget_security.xml new file mode 100644 index 0000000..1333c2a --- /dev/null +++ b/project_purchase_budget/security/project_purchase_budget_security.xml @@ -0,0 +1,18 @@ + + + + Project Purchase Budget Manager + + Can set the purchase budget percentage and sale override on projects. Also reviews purchase orders that exceed the project purchase budget. + + + + + + + diff --git a/project_purchase_budget/static/description/index.html b/project_purchase_budget/static/description/index.html new file mode 100644 index 0000000..e943535 --- /dev/null +++ b/project_purchase_budget/static/description/index.html @@ -0,0 +1,492 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Project Purchase Budget

+ +

Beta License: AGPL-3 Escodoo/project-addons

+

Limit purchases per project to a manager-defined percentage of the sale +value. Purchase orders that would exceed the budget require tier +validation approval.

+

The module computes the purchase budget as sale amount × percentage, +tracks committed spend from confirmed purchase orders (via analytic +distribution), and exposes project indicators: budget, spent, remaining, +consumed % and project contribution (sale − spent).

+

Note: Spend is based on confirmed purchase orders, not vendor bills.

+

Table of contents

+ +
+

Installation

+

To install this module, you need to:

+
    +
  1. Ensure the following modules are available and installable:
      +
    • project, purchase, sale_project, project_purchase
    • +
    • base_tier_validation, base_tier_validation_formula
    • +
    • purchase_tier_validation
    • +
    +
  2. +
  3. Install project_purchase_budget.
  4. +
  5. If the module was already present, upgrade it from Apps.
  6. +
+
+
+

Configuration

+

To configure this module, you need to:

+
    +
  1. Enable Analytic Accounting (Settings → Accounting).
  2. +
  3. Assign users who may set the percentage and approve over-budget +purchase orders to the group Project Purchase Budget Manager +(project managers get this group automatically via implication).
  4. +
  5. Open Settings → Technical → Tier Validations → Tier Definition and +confirm the record Purchase exceeds project budget exists:
      +
    • Model: Purchase Order
    • +
    • Definition type: Formula
    • +
    • Expression: rec.exceeds_project_purchase_budget
    • +
    • Reviewer group: Project Purchase Budget Manager
    • +
    +
  6. +
  7. Optionally add a second tier definition (higher sequence) for a +superior approval group if two approval levels are required.
  8. +
+
+
+

Usage

+

To use this module, you need to:

+
    +
  • Create or open a project with an analytic account.
  • +
  • Link a confirmed sales order (billable project + sales order item) +or set Sale Amount Override on the project.
  • +
  • Set Purchase Budget % (e.g. 60 for 60%).
  • +
  • Create purchase orders with analytic distribution on the project +analytic account.
  • +
  • If the purchase order would exceed the budget, a warning appears and +tier validation must be requested/approved before confirmation.
  • +
  • On the project Purchase Budget tab, review:
      +
    • Purchase Budget (planned)
    • +
    • Purchase Spent (actual / committed)
    • +
    • Purchase Remaining and Consumed %
    • +
    • Project Contribution (sale − spent)
    • +
    +
  • +
+
+
+

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

+
    +
  • Escodoo
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

Current maintainer:

+

marcelsavegnago

+

This module is part of the Escodoo/project-addons project on GitHub.

+

You are welcome to contribute.

+
+
+
+
+ + diff --git a/project_purchase_budget/tests/__init__.py b/project_purchase_budget/tests/__init__.py new file mode 100644 index 0000000..213eef7 --- /dev/null +++ b/project_purchase_budget/tests/__init__.py @@ -0,0 +1 @@ +from . import test_project_purchase_budget diff --git a/project_purchase_budget/tests/test_project_purchase_budget.py b/project_purchase_budget/tests/test_project_purchase_budget.py new file mode 100644 index 0000000..24bf5b0 --- /dev/null +++ b/project_purchase_budget/tests/test_project_purchase_budget.py @@ -0,0 +1,184 @@ +# Copyright 2026 - TODAY, Marcel Savegnago +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged("post_install", "-at_install") +class TestProjectPurchaseBudget(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + + cls.partner = cls.env["res.partner"].create( + { + "name": "REVARE Customer", + "company_type": "company", + } + ) + cls.vendor = cls.env["res.partner"].create( + { + "name": "Supplier Materials", + "company_type": "company", + "supplier_rank": 1, + } + ) + cls.analytic_plan = cls.env["account.analytic.plan"].create( + { + "name": "Projects Plan", + } + ) + cls.analytic_account = cls.env["account.analytic.account"].create( + { + "name": "Store Fit-out AA", + "plan_id": cls.analytic_plan.id, + } + ) + cls.project = cls.env["project.project"].create( + { + "name": "Store Fit-out Project", + "partner_id": cls.partner.id, + "analytic_account_id": cls.analytic_account.id, + "allow_billable": True, + } + ) + cls.product_sale = cls.env["product.product"].create( + { + "name": "Furniture Package", + "type": "service", + "list_price": 100000.0, + "invoice_policy": "order", + "taxes_id": False, + } + ) + cls.product_purchase = cls.env["product.product"].create( + { + "name": "Raw Material", + "type": "consu", + "standard_price": 1000.0, + "list_price": 1000.0, + "purchase_ok": True, + "taxes_id": False, + "supplier_taxes_id": False, + } + ) + cls.sale_order = cls.env["sale.order"].create( + { + "partner_id": cls.partner.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": cls.product_sale.id, + "product_uom_qty": 1.0, + "price_unit": 100000.0, + "tax_id": False, + }, + ) + ], + } + ) + cls.sale_order.action_confirm() + cls.project.write( + { + "sale_line_id": cls.sale_order.order_line[:1].id, + "purchase_budget_percent": 60.0, + } + ) + + def _create_purchase_order(self, amount, analytic_percent=100.0): + analytic_id = str(self.analytic_account.id) + return self.env["purchase.order"].create( + { + "partner_id": self.vendor.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": self.product_purchase.id, + "name": self.product_purchase.name, + "product_qty": 1.0, + "price_unit": amount, + "taxes_id": False, + "analytic_distribution": {analytic_id: analytic_percent}, + }, + ) + ], + } + ) + + def test_budget_from_sale_order(self): + self.assertAlmostEqual(self.project.purchase_budget_sale_amount, 100000.0) + self.assertAlmostEqual(self.project.purchase_budget_amount, 60000.0) + self.assertAlmostEqual(self.project.purchase_spent_amount, 0.0) + self.assertAlmostEqual(self.project.project_contribution_amount, 100000.0) + + def test_sale_override_changes_budget_and_margin(self): + self.project.purchase_budget_sale_override = 80000.0 + self.assertAlmostEqual(self.project.purchase_budget_sale_amount, 80000.0) + self.assertAlmostEqual(self.project.purchase_budget_amount, 48000.0) + self.assertAlmostEqual(self.project.project_contribution_amount, 80000.0) + + def test_po_within_budget_does_not_exceed(self): + po = self._create_purchase_order(50000.0) + self.assertFalse(po.exceeds_project_purchase_budget) + po.button_confirm() + self.assertEqual(po.state, "purchase") + self.project.invalidate_recordset() + self.assertAlmostEqual(self.project.purchase_spent_amount, 50000.0) + self.assertAlmostEqual(self.project.purchase_remaining_amount, 10000.0) + self.assertAlmostEqual(self.project.project_contribution_amount, 50000.0) + + def test_po_exceeding_budget_sets_flag(self): + po = self._create_purchase_order(70000.0) + self.assertTrue(po.exceeds_project_purchase_budget) + + def test_partial_analytic_distribution(self): + # Only 50% of a 100000 PO is allocated to the project (= 50000) + po = self._create_purchase_order(100000.0, analytic_percent=50.0) + self.assertFalse(po.exceeds_project_purchase_budget) + po.button_confirm() + self.project.invalidate_recordset() + self.assertAlmostEqual(self.project.purchase_spent_amount, 50000.0) + + # Another 50% of 30000 (= 15000) stays within remaining 10000? 50000+15000=65000 > 60000 + po2 = self._create_purchase_order(30000.0, analytic_percent=50.0) + self.assertTrue(po2.exceeds_project_purchase_budget) + + def test_cumulative_spend_triggers_exceed(self): + po1 = self._create_purchase_order(40000.0) + self.assertFalse(po1.exceeds_project_purchase_budget) + po1.button_confirm() + + po2 = self._create_purchase_order(25000.0) + # 40000 + 25000 = 65000 > 60000 + self.assertTrue(po2.exceeds_project_purchase_budget) + + def test_no_percent_means_no_control(self): + self.project.purchase_budget_percent = 0.0 + po = self._create_purchase_order(999999.0) + self.assertFalse(po.exceeds_project_purchase_budget) + + def test_duplicate_does_not_copy_exceed_flag(self): + po = self._create_purchase_order(70000.0) + self.assertTrue(po.exceeds_project_purchase_budget) + + # Flag must not be present in copy_data (never copied as stored value) + copy_vals = po.copy_data()[0] + self.assertNotIn("exceeds_project_purchase_budget", copy_vals) + + # Duplicate recalculates from current budget/lines (same lines → still True) + duplicate = po.copy() + self.assertTrue(duplicate.exceeds_project_purchase_budget) + + # After lowering the amount on the duplicate, flag must reflect new data + duplicate.order_line[0].price_unit = 10000.0 + duplicate.invalidate_recordset(["exceeds_project_purchase_budget"]) + self.assertFalse(duplicate.exceeds_project_purchase_budget) + # Source order must keep its own recomputed value + po.invalidate_recordset(["exceeds_project_purchase_budget"]) + self.assertTrue(po.exceeds_project_purchase_budget) diff --git a/project_purchase_budget/views/project_project_views.xml b/project_purchase_budget/views/project_project_views.xml new file mode 100644 index 0000000..74bdab4 --- /dev/null +++ b/project_purchase_budget/views/project_project_views.xml @@ -0,0 +1,52 @@ + + + + + project.project.form.purchase.budget + project.project + + + + + + + + + + + + + + + + + + +
+ Planned vs actual: Purchase Budget (planned) compared to + Purchase Spent (committed on confirmed purchase orders). + Project Contribution = Sale Amount − Purchase Spent. +
+
+
+
+
+
diff --git a/project_purchase_budget/views/purchase_order_views.xml b/project_purchase_budget/views/purchase_order_views.xml new file mode 100644 index 0000000..4d8001a --- /dev/null +++ b/project_purchase_budget/views/purchase_order_views.xml @@ -0,0 +1,30 @@ + + + + + purchase.order.form.project.purchase.budget + purchase.order + + + + + + + + + + + + diff --git a/setup/project_purchase_budget/odoo/addons/project_purchase_budget b/setup/project_purchase_budget/odoo/addons/project_purchase_budget new file mode 120000 index 0000000..5392b7e --- /dev/null +++ b/setup/project_purchase_budget/odoo/addons/project_purchase_budget @@ -0,0 +1 @@ +../../../../project_purchase_budget \ No newline at end of file diff --git a/setup/project_purchase_budget/setup.py b/setup/project_purchase_budget/setup.py new file mode 100644 index 0000000..28c57bb --- /dev/null +++ b/setup/project_purchase_budget/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) From 8ed20ba27acb35c3260b24489da5a15d46b62cf1 Mon Sep 17 00:00:00 2001 From: Marcel Savegnago Date: Wed, 15 Jul 2026 14:32:52 -0300 Subject: [PATCH 2/2] add new tests --- .../models/project_project.py | 1 + .../tests/test_project_purchase_budget.py | 233 ++++++++++++++++++ 2 files changed, 234 insertions(+) diff --git a/project_purchase_budget/models/project_project.py b/project_purchase_budget/models/project_project.py index 79739fd..54335e5 100644 --- a/project_purchase_budget/models/project_project.py +++ b/project_purchase_budget/models/project_project.py @@ -65,6 +65,7 @@ class ProjectProject(models.Model): compute="_compute_is_purchase_budget_manager", ) + @api.depends_context("uid") def _compute_is_purchase_budget_manager(self): is_manager = self.env.user.has_group( "project_purchase_budget.group_purchase_budget_manager" diff --git a/project_purchase_budget/tests/test_project_purchase_budget.py b/project_purchase_budget/tests/test_project_purchase_budget.py index 24bf5b0..5cc559d 100644 --- a/project_purchase_budget/tests/test_project_purchase_budget.py +++ b/project_purchase_budget/tests/test_project_purchase_budget.py @@ -1,6 +1,9 @@ # Copyright 2026 - TODAY, Marcel Savegnago # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from unittest.mock import patch + +from odoo import fields from odoo.tests import tagged from odoo.tests.common import TransactionCase @@ -88,6 +91,9 @@ def setUpClass(cls): "purchase_budget_percent": 60.0, } ) + cls.budget_manager_group = cls.env.ref( + "project_purchase_budget.group_purchase_budget_manager" + ) def _create_purchase_order(self, amount, analytic_percent=100.0): analytic_id = str(self.analytic_account.id) @@ -182,3 +188,230 @@ def test_duplicate_does_not_copy_exceed_flag(self): # Source order must keep its own recomputed value po.invalidate_recordset(["exceeds_project_purchase_budget"]) self.assertTrue(po.exceeds_project_purchase_budget) + + def test_is_purchase_budget_manager_compute(self): + manager_user = self.env["res.users"].create( + { + "name": "Budget Manager", + "login": "budget_manager_test", + "groups_id": [ + ( + 6, + 0, + [ + self.env.ref("base.group_user").id, + self.budget_manager_group.id, + ], + ) + ], + } + ) + plain_user = self.env["res.users"].create( + { + "name": "Plain User", + "login": "plain_budget_test", + "groups_id": [(6, 0, [self.env.ref("base.group_user").id])], + } + ) + self.assertTrue(self.project.with_user(manager_user).is_purchase_budget_manager) + self.assertFalse(self.project.with_user(plain_user).is_purchase_budget_manager) + + def test_sale_orders_without_sale_order_id(self): + project = self.env["project.project"].create( + { + "name": "No SO Link", + "partner_id": self.partner.id, + "analytic_account_id": self.analytic_account.id, + "allow_billable": True, + "purchase_budget_percent": 50.0, + "purchase_budget_sale_override": 20000.0, + } + ) + self.assertFalse(project.sale_order_id) + self.assertFalse(project._get_purchase_budget_sale_orders()) + self.assertAlmostEqual(project.purchase_budget_sale_amount, 20000.0) + + def test_sale_orders_includes_sale_order_id(self): + orders = self.project._get_purchase_budget_sale_orders() + self.assertIn(self.sale_order, orders) + self.assertTrue(self.project.sale_order_id) + + def test_spent_without_analytic_account_is_zero(self): + project = self.env["project.project"].create( + { + "name": "No Analytic", + "partner_id": self.partner.id, + "purchase_budget_percent": 50.0, + "purchase_budget_sale_override": 10000.0, + } + ) + project.analytic_account_id = False + self.assertEqual(project._get_confirmed_purchase_spent(), 0.0) + self.assertAlmostEqual(project.purchase_spent_amount, 0.0) + + def test_convert_to_project_currency_same_or_empty(self): + amount = 1234.56 + self.assertAlmostEqual( + self.project._convert_to_project_currency(amount, False), + amount, + ) + self.assertAlmostEqual( + self.project._convert_to_project_currency(amount, self.project.currency_id), + amount, + ) + + def test_convert_to_project_currency_different_currency(self): + company_currency = self.project.currency_id + other_currency = self.env.ref("base.USD") + if other_currency == company_currency: + other_currency = self.env.ref("base.EUR") + other_currency.active = True + self.env["res.currency.rate"].create( + { + "name": fields.Date.today(), + "currency_id": other_currency.id, + "rate": 2.0, + "company_id": self.env.company.id, + } + ) + converted = self.project._convert_to_project_currency(100.0, other_currency) + expected = other_currency._convert( + 100.0, + company_currency, + self.env.company, + fields.Date.today(), + ) + self.assertAlmostEqual(converted, expected) + + def test_spent_skips_rows_without_project_analytic_key(self): + """Cover defensive continue when distribution lacks the project analytic.""" + company = self.project.company_id or self.env.company + currency = self.project.currency_id + # Warm currency/company cache (do not mock cr.execute — it breaks ORM reads) + _ = currency.rounding + rows = [ + { + "id": 1, + "price_subtotal": 5000.0, + "currency_id": currency.id, + "company_id": company.id, + "analytic_distribution": {}, + }, + { + "id": 2, + "price_subtotal": 1000.0, + "currency_id": currency.id, + "company_id": company.id, + "analytic_distribution": {str(self.analytic_account.id): 100.0}, + }, + ] + with patch.object(self.env.cr, "dictfetchall", return_value=rows): + spent = self.project._get_confirmed_purchase_spent() + self.assertAlmostEqual(spent, 1000.0) + + def test_spent_with_exclude_order_ids(self): + po1 = self._create_purchase_order(10000.0) + po1.button_confirm() + po2 = self._create_purchase_order(5000.0) + po2.button_confirm() + self.project.invalidate_recordset() + spent_all = self.project._get_confirmed_purchase_spent() + spent_excl = self.project._get_confirmed_purchase_spent( + exclude_order_ids=po1.ids, + ) + self.assertAlmostEqual(spent_all, 15000.0) + self.assertAlmostEqual(spent_excl, 5000.0) + + def test_budget_control_enabled(self): + self.assertTrue(self.project._budget_control_enabled()) + self.project.purchase_budget_percent = 0.0 + self.assertFalse(self.project._budget_control_enabled()) + + def test_po_without_analytic_has_no_project_amounts(self): + po = self.env["purchase.order"].create( + { + "partner_id": self.vendor.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": self.product_purchase.id, + "name": self.product_purchase.name, + "product_qty": 1.0, + "price_unit": 99999.0, + "taxes_id": False, + }, + ) + ], + } + ) + self.assertEqual(po._get_project_amounts_from_order(), {}) + self.assertFalse(po._order_exceeds_any_project_budget()) + self.assertFalse(po.exceeds_project_purchase_budget) + self.assertFalse(po._get_affected_budget_projects()) + + def test_po_analytic_without_project_is_ignored(self): + orphan_aa = self.env["account.analytic.account"].create( + { + "name": "Orphan Analytic", + "plan_id": self.analytic_plan.id, + } + ) + po = self.env["purchase.order"].create( + { + "partner_id": self.vendor.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": self.product_purchase.id, + "name": self.product_purchase.name, + "product_qty": 1.0, + "price_unit": 99999.0, + "taxes_id": False, + "analytic_distribution": {str(orphan_aa.id): 100.0}, + }, + ) + ], + } + ) + self.assertEqual(po._get_project_amounts_from_order(), {}) + self.assertFalse(po._order_exceeds_any_project_budget()) + self.assertFalse(po.exceeds_project_purchase_budget) + self.assertFalse(po._get_affected_budget_projects()) + + def test_po_with_project_and_orphan_analytic_only_counts_project(self): + orphan_aa = self.env["account.analytic.account"].create( + { + "name": "Orphan Analytic Mix", + "plan_id": self.analytic_plan.id, + } + ) + po = self.env["purchase.order"].create( + { + "partner_id": self.vendor.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": self.product_purchase.id, + "name": "Project share", + "product_qty": 1.0, + "price_unit": 10000.0, + "taxes_id": False, + "analytic_distribution": { + str(self.analytic_account.id): 50.0, + str(orphan_aa.id): 50.0, + }, + }, + ) + ], + } + ) + amounts = po._get_project_amounts_from_order() + self.assertEqual(list(amounts.keys()), [self.project.id]) + self.assertAlmostEqual(amounts[self.project.id], 5000.0) + self.assertFalse(po.exceeds_project_purchase_budget)