Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions project_purchase_budget/README.rst
Original file line number Diff line number Diff line change
@@ -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 <https://github.com/Escodoo/project-addons/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 <https://github.com/Escodoo/project-addons/issues/new?body=module:%20project_purchase_budget%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.

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

Credits
=======

Authors
~~~~~~~

* Escodoo

Contributors
~~~~~~~~~~~~

* `Escodoo <https://www.escodoo.com.br>`_:

* Marcel Savegnago <marcel.savegnago@escodoo.com.br>

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 <https://github.com/Escodoo/project-addons/tree/16.0/project_purchase_budget>`_ project on GitHub.

You are welcome to contribute.
1 change: 1 addition & 0 deletions project_purchase_budget/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
29 changes: 29 additions & 0 deletions project_purchase_budget/__manifest__.py
Original file line number Diff line number Diff line change
@@ -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"],
}
18 changes: 18 additions & 0 deletions project_purchase_budget/data/tier_definition_data.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo noupdate="1">
<record id="tier_definition_po_exceeds_project_budget" model="tier.definition">
<field name="name">Purchase exceeds project budget</field>
<field name="model_id" ref="purchase.model_purchase_order" />
<field name="review_type">group</field>
<field
name="reviewer_group_id"
ref="project_purchase_budget.group_purchase_budget_manager"
/>
<field name="definition_type">formula</field>
<field name="python_code">rec.exceeds_project_purchase_budget</field>
<field name="sequence">10</field>
<field name="company_id" eval="False" />
<field name="has_comment">True</field>
<field name="notify_on_create">True</field>
</record>
</odoo>
2 changes: 2 additions & 0 deletions project_purchase_budget/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from . import project_project
from . import purchase_order
195 changes: 195 additions & 0 deletions project_purchase_budget/models/project_project.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
# Copyright 2026 - TODAY, Marcel Savegnago <marcel.savegnago@escodoo.com.br>
# 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",
)

@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"
)
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,
)
Loading
Loading