217 lines
10 KiB
Python
217 lines
10 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
from odoo import fields, models
|
|
from odoo.fields import Domain
|
|
from odoo.tools import SQL, Query
|
|
|
|
|
|
class BudgetReport(models.Model):
|
|
_name = 'budget.report'
|
|
_inherit = ['analytic.plan.fields.mixin']
|
|
_description = "Budget Report"
|
|
_auto = False
|
|
_order = 'date desc'
|
|
|
|
date = fields.Date('Date')
|
|
res_model = fields.Char('Model', readonly=True)
|
|
res_id = fields.Many2oneReference('Document', model_field='res_model', readonly=True)
|
|
description = fields.Char('Description', readonly=True)
|
|
company_id = fields.Many2one('res.company', 'Company', readonly=True)
|
|
user_id = fields.Many2one('res.users', 'User', readonly=True)
|
|
line_type = fields.Selection([('budget', 'Budget'), ('achieved', 'Achieved')], 'Type', readonly=True)
|
|
budget = fields.Float('Budget', readonly=True)
|
|
achieved = fields.Float('Achieved', readonly=True)
|
|
theoretical = fields.Float(readonly=True)
|
|
budget_analytic_id = fields.Many2one('budget.analytic', 'Budget Analytic', readonly=True)
|
|
budget_line_id = fields.Many2one('budget.line', 'Budget Line', readonly=True)
|
|
|
|
def _shape_join(self, model1, alias1, model2, alias2, shape):
|
|
plan_fnames = self._get_plan_fnames()
|
|
return SQL(' AND ').join([
|
|
SQL(
|
|
"%(model1)s = %(model2)s",
|
|
model1=self.env[model1]._field_to_sql(alias1, fname),
|
|
model2=self.env[model2]._field_to_sql(alias2, fname),
|
|
)
|
|
for fname, is_set in zip(plan_fnames, shape)
|
|
if is_set
|
|
]) or SQL('TRUE')
|
|
|
|
def _get_bl_query(self, plan_fnames):
|
|
budget_line_domain = self.env.context.get('budget_line_domain')
|
|
return SQL(
|
|
"""
|
|
SELECT CONCAT('bl', bl.id::TEXT) AS id,
|
|
bl.budget_analytic_id AS budget_analytic_id,
|
|
bl.id AS budget_line_id,
|
|
'budget.analytic' AS res_model,
|
|
bl.budget_analytic_id AS res_id,
|
|
bl.date_from AS date,
|
|
ba.name AS description,
|
|
bl.company_id AS company_id,
|
|
NULL AS user_id,
|
|
'budget' AS line_type,
|
|
bl.budget_amount AS budget,
|
|
0 AS committed, -- used in `account_budget_purchase`
|
|
0 AS achieved,
|
|
CASE WHEN %(today)s BETWEEN bl.date_from AND bl.date_to
|
|
THEN (((%(today)s::DATE - bl.date_from::DATE + 1)) / (bl.date_to::DATE - bl.date_from::DATE + 1)::FLOAT) * bl.budget_amount
|
|
WHEN %(today)s < bl.date_from
|
|
THEN 0
|
|
ELSE bl.budget_amount
|
|
END AS theoretical,
|
|
%(plan_fields)s
|
|
FROM budget_line bl
|
|
JOIN budget_analytic ba ON ba.id = bl.budget_analytic_id
|
|
%(budget_line_condition)s
|
|
""",
|
|
plan_fields=SQL(', ').join(self.env['budget.line']._field_to_sql('bl', fname) for fname in plan_fnames),
|
|
budget_line_condition=SQL('WHERE %s', budget_line_domain._to_sql(self, 'bl', None)) if budget_line_domain else SQL(''),
|
|
today=fields.Date.context_today(self),
|
|
)
|
|
|
|
def _get_aal_query(self, plan_fnames):
|
|
def get_query(bl_join, bl_condition):
|
|
query = Query(self.env, alias='aal', table=SQL.identifier('account_analytic_line'))
|
|
query.add_join(bl_join, 'bl', 'budget_line', bl_condition)
|
|
query.add_join('LEFT JOIN', 'ba', 'budget_analytic', SQL("ba.id = bl.budget_analytic_id"))
|
|
return query
|
|
|
|
budget_line_domain = self.env.context.get('budget_line_domain') or []
|
|
|
|
# For performance reasons, we split the query into 3 parts and then UNION ALL the results
|
|
# (faster than doing a single query with an OR on the left join condition):
|
|
# Q1 - analytic lines with no matching budget line at all.
|
|
# Q2 - analytic lines matched to a null-company budget line.
|
|
# Q3 - analytic lines matched to a company-specific budget line.
|
|
|
|
select_columns = [
|
|
"CONCAT('aal', aal.id::TEXT) AS id",
|
|
"bl.budget_analytic_id AS budget_analytic_id",
|
|
"bl.id AS budget_line_id",
|
|
"'account.analytic.line' AS res_model",
|
|
"aal.id AS res_id",
|
|
"aal.date AS date",
|
|
"aal.name AS description",
|
|
"aal.company_id AS company_id",
|
|
"aal.user_id AS user_id",
|
|
"'achieved' AS line_type",
|
|
"0 AS budget",
|
|
"aal.amount * CASE WHEN ba.budget_type = 'expense' THEN -1 ELSE 1 END AS committed", # used in `account_budget_purchase`
|
|
"aal.amount * CASE WHEN ba.budget_type = 'expense' THEN -1 ELSE 1 END AS achieved",
|
|
"0 AS theoretical",
|
|
*(self.env['account.analytic.line']._field_to_sql('aal', fname) for fname in plan_fnames),
|
|
]
|
|
|
|
def where_account_type(query):
|
|
query.add_join('LEFT JOIN', 'aa', 'account_account', SQL("aa.id = aal.general_account_id"))
|
|
return SQL("""(
|
|
SPLIT_PART(aa.account_type, '_', 1) IN ('income', 'expense')
|
|
OR aa.account_type IN ('asset_current', 'asset_non_current', 'asset_fixed')
|
|
OR aa.account_type IS NULL
|
|
)""")
|
|
|
|
budget_lines = self.env['budget.line'].sudo().search(budget_line_domain)
|
|
|
|
# Group budget lines by the shape of their plan fields to optimize the query
|
|
# For each budget line, we only care which fields are set, not their specific values.
|
|
shape2budget_lines = budget_lines._by_shape()
|
|
|
|
queries = []
|
|
|
|
# Q1 - analytic lines with no matching budget line at all.
|
|
if not budget_line_domain:
|
|
query = get_query('LEFT JOIN', SQL("FALSE"))
|
|
# filter it so that we only get the aals that have no bl
|
|
if shape2budget_lines:
|
|
query.add_where(SQL(
|
|
"NOT EXISTS (SELECT 1 FROM (%(union)s) matched_aal WHERE matched_aal.id = aal.id)",
|
|
union=SQL(' UNION ALL ').join([
|
|
get_query('JOIN', SQL(
|
|
"""
|
|
aal.date >= bl.date_from
|
|
AND aal.date <= bl.date_to
|
|
AND bl.id = ANY(%(ids)s)
|
|
AND %(shape_join)s
|
|
""",
|
|
ids=line_ids.ids,
|
|
shape_join=self._shape_join('account.analytic.line', 'aal', 'budget.line', 'bl', shape),
|
|
)).select("DISTINCT aal.id")
|
|
for shape, line_ids in shape2budget_lines.items()
|
|
])
|
|
))
|
|
query.add_where(where_account_type(query))
|
|
queries.append(query.select(*select_columns))
|
|
|
|
# Q2 - analytic lines matched to a null-company budget line.
|
|
# Q3 - analytic lines matched to a company-specific budget line.
|
|
company_conditions = [
|
|
SQL('bl.company_id IS NULL'),
|
|
SQL('aal.company_id = bl.company_id'),
|
|
]
|
|
|
|
for company_condition in company_conditions:
|
|
for shape, line_ids in shape2budget_lines.items():
|
|
query = get_query('JOIN', SQL(
|
|
"""
|
|
%(company_condition)s
|
|
AND aal.date >= bl.date_from
|
|
AND aal.date <= bl.date_to
|
|
AND bl.id = ANY(%(ids)s)
|
|
AND %(shape_join)s
|
|
""",
|
|
company_condition=company_condition,
|
|
ids=line_ids.ids,
|
|
shape_join=self._shape_join('account.analytic.line', 'aal', 'budget.line', 'bl', shape),
|
|
))
|
|
query.add_where(SQL(
|
|
"""CASE
|
|
WHEN ba.budget_type = 'expense' THEN ((%(profitability)s) = 'loss')
|
|
WHEN ba.budget_type = 'revenue' THEN ((%(profitability)s) = 'revenue')
|
|
ELSE TRUE
|
|
END""",
|
|
profitability=self.env['account.analytic.line']._field_to_sql('aal', 'analytic_profitability', query),
|
|
))
|
|
query.add_where(where_account_type(query))
|
|
queries.append(query.select(*select_columns))
|
|
|
|
return SQL(' UNION ALL ').join(queries)
|
|
|
|
@property
|
|
def _table_query(self):
|
|
self.env['account.move.line'].flush_model()
|
|
self.env['budget.line'].flush_model()
|
|
self.env['account.analytic.line'].flush_model()
|
|
project_plan, other_plans = self.env['account.analytic.plan']._get_all_plans()
|
|
plan_fnames = [
|
|
fname
|
|
for plan in project_plan | other_plans
|
|
if (fname := plan._column_name()) in self
|
|
]
|
|
return SQL(" UNION ALL ").join(filter(None, (
|
|
self._get_bl_query(plan_fnames),
|
|
self._get_aal_query(plan_fnames),
|
|
)))
|
|
|
|
def _search(self, domain, offset=0, limit=None, order=None, *, active_test=True, bypass_access=False):
|
|
budget_line_domain = Domain(domain).map_conditions(lambda cond: (
|
|
cond if cond.field_expr == 'budget_analytic_id'
|
|
else Domain('id', cond.operator, cond.value) if cond.field_expr == 'budget_line_id'
|
|
else fields.Domain.TRUE
|
|
)).optimize_full(self.env['budget.line'])
|
|
return super(BudgetReport, self.with_context(budget_line_domain=budget_line_domain))._search(
|
|
domain, offset=offset, limit=limit, order=order, active_test=active_test, bypass_access=bypass_access,
|
|
)
|
|
|
|
def action_open_reference(self):
|
|
self.ensure_one()
|
|
if self.res_model == 'account.analytic.line':
|
|
analytical_line = self.env['account.analytic.line'].browse(self.res_id)
|
|
if analytical_line.move_line_id:
|
|
return analytical_line.move_line_id.action_open_business_doc()
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': self.res_model,
|
|
'view_mode': 'form',
|
|
'res_id': self.res_id,
|
|
}
|