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
4 changes: 3 additions & 1 deletion budget_control/models/account_move.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,9 @@ def write(self, vals):
if vals.get("state") in ("posted", "cancel", "draft") or vals.get(
"not_affect_budget"
):
self.mapped("invoice_line_ids").recompute_budget_move()
self.mapped("invoice_line_ids")._call_budget_method_guarded(
"recompute_budget_move"
)
return res

def _filtered_move_check_budget(self):
Expand Down
20 changes: 20 additions & 0 deletions budget_control/models/base_budget_move.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,26 @@ def _budget_model(self):
def _budget_field(self):
return self.env.context.get("alt_budget_move_field") or self._budget_move_field

def _call_budget_method_guarded(self, method_name):
"""Call <method_name>() unless it's already in progress for this
exact recordset higher up the current call stack (write() MRO can
re-enter for the same records).
"""
if not self:
return
in_progress = getattr(self.env.cr, "_budget_recompute_in_progress", None)
if in_progress is None:
in_progress = set()
self.env.cr._budget_recompute_in_progress = in_progress
key = (self._name, method_name, frozenset(self.ids))
if key in in_progress:
return
in_progress.add(key)
try:
getattr(self, method_name)()
finally:
in_progress.discard(key)

def _valid_commit_state(self):
raise ValidationError(self.env._("No implementation error!"))

Expand Down
222 changes: 158 additions & 64 deletions budget_control/models/budget_period.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Copyright 2020 Ecosoft Co., Ltd. (http://ecosoft.co.th)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

from collections import defaultdict

from odoo import api, fields, models
from odoo.exceptions import UserError, ValidationError
from odoo.tools import SQL, float_compare, format_amount
Expand Down Expand Up @@ -135,29 +137,19 @@ def _get_budget_constraint(self):
)

@api.model
def check_budget(self, doclines, doc_type="account"):
"""
Check the budget based on the input budget moves, i.e., account_move_line.
1. Get a valid budget period (how budget is being controlled).
2. Determine which account (KPI) and analytic
to control based on (1) and doclines.
3. Check for negative budget and return warnings
based on (2) and the KPI matrix.
"""
if self._context.get("force_no_budget_check"):
return
doclines = doclines.filtered("can_commit")
if not doclines:
return
self = self.sudo()
budget_constraints = self._get_budget_constraint()
all_analytics = doclines.mapped(doclines._budget_analytic_field)
def _group_doclines_by_analytic(self, doclines):
"""Group document lines by every analytic in their distribution.

# Get All Analytic Account
all_analytic_ids = set()
for data_dict in all_analytics:
Odoo stores analytics from multiple plans in comma-separated JSON keys,
for example ``{"12,34": 100}``. Build all groups in one pass so those
keys are handled correctly without repeatedly filtering every docline.
"""
grouped_line_ids = defaultdict(list)
analytic_field = doclines._budget_analytic_field
for line in doclines:
distribution = line[analytic_field] or {}
# Check percent analytic account must be 100% only
total_sum = sum(data_dict.values())
total_sum = sum(distribution.values())
if (
float_compare(
total_sum,
Expand All @@ -172,61 +164,101 @@ def check_budget(self, doclines, doc_type="account"):
"Please check again."
)
)
all_analytic_ids.update(
int(aa) for key in data_dict.keys() for aa in key.split(",")
)
analytic_ids = {
int(analytic_id)
for key in distribution
for analytic_id in key.split(",")
}
for analytic_id in sorted(analytic_ids):
grouped_line_ids[analytic_id].append(line.id)
return [
(analytic_id, doclines.browse(line_ids))
for analytic_id, line_ids in grouped_line_ids.items()
]

@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 all_analytic_ids:
if isinstance(aa, int):
doclines = doclines.filtered(
lambda line, aa=aa, doclines=doclines: line[
doclines._budget_analytic_field
].get(str(aa))
)
else:
doclines = doclines.filtered(
lambda line, aa=aa, doclines=doclines: line[
doclines._budget_analytic_field
]
== aa
)
# Pass 1: resolve each analytic account's group, no DB hit yet.
groups = []
period_cache = {}
controls_cache = {}
for analytic_id, aa_doclines in self._group_doclines_by_analytic(doclines):
# Find active budget.period based on latest doclines date_commit
date_commit = doclines.filtered("date_commit").mapped("date_commit")
date_commit = aa_doclines.filtered("date_commit").mapped("date_commit")
if not date_commit:
return
continue
date_commit = max(date_commit)
budget_period = self._get_eligible_budget_period(
date_commit, doc_type=doc_type
)
if date_commit not in period_cache:
period_cache[date_commit] = self._get_eligible_budget_period(
date_commit, doc_type=doc_type
)
budget_period = period_cache[date_commit]
if not budget_period:
return
# Find combination of account (KPI) + analytic (i.e., project) to control
controls = self._prepare_controls(budget_period, doclines)
continue
# Find KPI controls only for this analytic. A line may contain several
# analytics and consequently several budget moves.
controls_key = (budget_period.id, tuple(aa_doclines.ids))
if controls_key not in controls_cache:
controls_cache[controls_key] = self._prepare_controls(
budget_period, aa_doclines
)
controls = [
control
for control in controls_cache[controls_key]
if control["analytic_id"] == analytic_id
]
if not controls:
return
# The budget_control of these analytics must be active
if isinstance(aa, int):
analytic_ids = all_analytic_ids
else:
analytic_ids = [x["analytic_id"] for x in controls]
analytics = self.env["account.analytic.account"].browse(analytic_ids)
analytics._check_budget_control_status(budget_period_id=budget_period.id)
continue
groups.append((aa_doclines, date_commit, budget_period, controls))
if not groups:
return
# Validate each period's analytics in one query before the monitor scan.
analytic_ids_by_period = defaultdict(set)
for _doclines, _date, budget_period, controls in groups:
analytic_ids_by_period[budget_period.id].update(
control["analytic_id"] for control in controls
)
for budget_period_id, analytic_ids in analytic_ids_by_period.items():
self.env["account.analytic.account"].browse(
sorted(analytic_ids)
)._check_budget_control_status(budget_period_id=budget_period_id)
# Pass 2: one shared prefetch instead of one scan per control.
all_controls = [c for group in groups for c in group[3]]
avail_cache = self._prefetch_budget_available(all_controls)
self = self.with_context(_budget_avail_cache=avail_cache)
for aa_doclines, date_commit, budget_period, controls in groups:
# Check budget on each control element against each KPI/avail (period)
currency = (
"currency_id" in doclines
and doclines.mapped("currency_id")[:1]
"currency_id" in aa_doclines
and aa_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
date_commit=date_commit, doc_currency=currency, doclines=aa_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)
elif aa_doclines and budget_constraints and budget_period:
self.check_budget_constraint(budget_constraints, aa_doclines)
return

@api.model
Expand Down Expand Up @@ -429,9 +461,71 @@ def _get_budget_monitor_report(self):
"""Hook for add context"""
return self.env["budget.monitor.report"]

def _can_use_budget_available_cache(self, template_lines):
"""Whether the shared cache represents this availability query.

Extensions adding dimensions to ``_get_where_domain()`` should override
this hook and return ``False`` for template-line models that need those
extra filters.
"""
return not template_lines or template_lines._name == "budget.template.line"

def _prefetch_budget_available(self, controls):
"""One monitor-report scan for all controls' analytic accounts, keyed
by analytic_id. ``check_budget()`` can then serve every control from
this dict instead of running one SQL query per control.

Only the base filter shape (``analytic_account_id IN (...)``) is
served here; ``_get_where_domain()`` overrides that add extra clauses
(e.g. budget_plan_detail's ``fund_id``) are not covered and must keep
using the per-control ``_get_budget_avaiable()`` path.
"""
analytic_ids = sorted(
{c["analytic_id"] for c in controls if c.get("analytic_id")}
)
if not analytic_ids:
return {}
self.env.flush_all()
report_sql = self._get_budget_monitor_report()._table_query
self.env.cr.execute(
SQL(
"""
SELECT
analytic_account_id,
kpi_id,
budget_period_id,
amount_type,
amount
FROM (%s) report
WHERE analytic_account_id IN %s
""",
SQL(report_sql),
tuple(analytic_ids),
)
)
cache = {}
for row in self.env.cr.dictfetchall():
cache.setdefault(row["analytic_account_id"], []).append(row)
return cache

def _get_budget_avaiable(self, analytic_id, template_lines):
# Callers that batch many queries can set env.context['skip_budget_flush']
# after flushing once themselves, avoiding a flush_all() per control.
# check_budget() additionally pre-scans every analytic once and passes
# the rows through env.context['_budget_avail_cache']; we then filter
# by kpi_id in Python when control_level != "analytic".
cache = self.env.context.get("_budget_avail_cache")
# The cache only covers the base filter shape; custom _get_where_domain
# overrides (e.g. budget_plan_detail's fund_id) fall through to a query.
if cache is not None and self._can_use_budget_available_cache(template_lines):
rows = cache.get(analytic_id, [])
if (
template_lines
and self._context.get("control_level", False) != "analytic"
):
kpi_ids = set(template_lines.kpi_id.ids)
rows = [r for r in rows if r.get("kpi_id") in kpi_ids]
return rows
if not self.env.context.get("skip_budget_flush"):
self.env.flush_all()
self.env.cr.execute(
Expand Down Expand Up @@ -462,11 +556,11 @@ def _check_budget_available(self, controls, budget_period):
company = self.env.user.company_id
doc_currency = self.env.context.get("doc_currency")
date_commit = self.env.context.get("date_commit")
# Flush once for all controls. Budget moves are not written while this
# loop runs (it only reads and raises/returns), so a single flush before
# the loop is enough and avoids repeating the expensive flush_all() per
# control when checking many analytics/KPIs.
self.env.flush_all()
# A shared cache is built only after flushing, and nothing is written
# while the control groups are checked. Without a cache, flush once here
# before the per-control fallback queries.
if self.env.context.get("_budget_avail_cache") is None:
self.env.flush_all()
self = self.with_context(skip_budget_flush=True)
for control in controls:
analytic_id = control["analytic_id"]
Expand Down
54 changes: 54 additions & 0 deletions budget_control/tests/test_budget_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -1660,3 +1660,57 @@ def _add_invoice_line(bill, account, not_affect_budget=False):
self.assertEqual(
sum(len(line.budget_move_ids) for line in bill3.invoice_line_ids), 0
)

@freeze_time("2001-02-01")
def test_27_multi_analytic_budget_check(self):
"""Every analytic used by a document must be budget checked."""
self.budget_period.control_budget = True
self.budget_period.control_level = "analytic"
budget_controls = self.budget_control | self.budget_control2
budget_controls.action_submit()
budget_controls.action_done()

bill = self._create_simple_bill(
{self.costcenter1.id: 100}, self.account_kpi1, 100
)
second_line_vals = {
"quantity": 1,
"account_id": self.account_kpi1.id,
"price_unit": 100000,
"analytic_distribution": {self.costcenterX.id: 100},
}
if getattr(self, "check_plan_detail_installed", False):
second_line_vals.update(
{
"fund_id": self.fund1_g1.id,
"analytic_tag_ids": [Command.link(self.analytic_tag1.id)],
}
)
bill.write({"invoice_line_ids": [Command.create(second_line_vals)]})

with self.assertRaisesRegex(UserError, "Budget not sufficient"):
bill.action_post()

@freeze_time("2001-02-01")
def test_28_combined_analytic_distribution_key(self):
"""Comma-separated analytic keys must not bypass budget checks."""
self.budget_period.control_budget = True
self.budget_period.control_level = "analytic"
budget_controls = self.budget_control | self.budget_control2
budget_controls.action_submit()
budget_controls.action_done()

distribution_key = f"{self.costcenter1.id},{self.costcenterX.id}"
bill = self._create_simple_bill(
{distribution_key: 100}, self.account_kpi1, 100000
)

grouped_analytics = {
analytic_id
for analytic_id, _lines in self.env[
"budget.period"
]._group_doclines_by_analytic(bill.invoice_line_ids)
}
self.assertEqual(grouped_analytics, {self.costcenter1.id, self.costcenterX.id})
with self.assertRaisesRegex(UserError, "Budget not sufficient"):
bill.action_post()
4 changes: 3 additions & 1 deletion budget_control_expense/models/account_move.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,7 @@ def write(self, vals):
"""Uncommit budget for source expense document."""
res = super().write(vals)
if vals.get("state") in ("draft", "posted", "cancel"):
self.mapped("line_ids.expense_id").recompute_budget_move()
self.mapped("line_ids.expense_id")._call_budget_method_guarded(
"recompute_budget_move"
)
return res
Loading
Loading