From f8842740131e3ce481198fb7679c0a6db777b4ac Mon Sep 17 00:00:00 2001 From: Mazen Abdo Date: Thu, 9 Oct 2025 04:02:59 +0300 Subject: [PATCH] PO Budget Enhancement --- .../purchase_budget_fix/__manifest__.py | 3 +- .../purchase_budget_fix/models/model.py | 196 +++++++++++++++--- .../purchase_budget_fix/views/views.xml | 42 ++++ 3 files changed, 215 insertions(+), 26 deletions(-) create mode 100644 odex25_purchase/purchase_budget_fix/views/views.xml diff --git a/odex25_purchase/purchase_budget_fix/__manifest__.py b/odex25_purchase/purchase_budget_fix/__manifest__.py index 9d717e42c..94e270d03 100644 --- a/odex25_purchase/purchase_budget_fix/__manifest__.py +++ b/odex25_purchase/purchase_budget_fix/__manifest__.py @@ -7,9 +7,10 @@ 'website': '', 'license': 'LGPL-3', 'category': 'purchase', - 'depends': ['base',"purchase_requisition_custom"], + 'depends': ['base',"purchase_requisition_custom",'account','account_budget_custom'], 'data': [ # 'security/ir.model.access.csv', + 'views/views.xml', ], } \ No newline at end of file diff --git a/odex25_purchase/purchase_budget_fix/models/model.py b/odex25_purchase/purchase_budget_fix/models/model.py index f928563d2..1b6568557 100644 --- a/odex25_purchase/purchase_budget_fix/models/model.py +++ b/odex25_purchase/purchase_budget_fix/models/model.py @@ -1,7 +1,136 @@ # -*- coding: utf-8 -*- -from odoo import models, _, api +from odoo import models, fields, api, _ from odoo.exceptions import ValidationError + +class PurchaseOrderLine(models.Model): + _inherit = 'purchase.order.line' + + budget_id = fields.Many2one('crossovered.budget',string='Budget',help='Only approved budgets available for selected Analytic Account',) + budget_position_id = fields.Many2one('account.budget.post',string='Budgetary Position',help='Filtered by the selected Budget',) + budget_account_id = fields.Many2one('account.account',string='Budgetary Position Account',help='Filtered by selected Budgetary Position',) + selected_budget_line_id = fields.Many2one('crossovered.budget.lines',string='Selected Budget Line',help='The specific budget line to use for this order line',compute='_compute_selected_budget_line',store=True) + + @api.depends('budget_id', 'budget_position_id', 'account_analytic_id') + def _compute_selected_budget_line(self): + #Compute the budget line based on selections + for line in self: + if line.budget_id and line.budget_position_id and line.account_analytic_id: + budget_line = self.env['crossovered.budget.lines'].search([ + ('crossovered_budget_id', '=', line.budget_id.id), + ('general_budget_id', '=', line.budget_position_id.id), + ('analytic_account_id', '=', line.account_analytic_id.id) + ], limit=1) + line.selected_budget_line_id = budget_line + else: + line.selected_budget_line_id = False + + @api.onchange('account_analytic_id') + def _onchange_account_analytic_id(self): + # When analytic account changes, restrict budgets + self.ensure_one() + if self.account_analytic_id and self.order_id.date_order: + budget_lines = self.env['crossovered.budget.lines'].search([ + ('analytic_account_id', '=', self.account_analytic_id.id), + ('crossovered_budget_id.state', '=', 'done'), + ('crossovered_budget_id.date_from', '<=', self.order_id.date_order), + ('crossovered_budget_id.date_to', '>=', self.order_id.date_order), + ]) + budget_ids = budget_lines.mapped('crossovered_budget_id').ids + + return { + 'domain': { + 'budget_id': [('id', 'in', budget_ids)] + }, + 'value': { + 'budget_id': False, + 'budget_position_id': False, + 'budget_account_id': False, + }, + } + else: + return { + 'domain': {'budget_id': []}, + 'value': { + 'budget_id': False, + 'budget_position_id': False, + 'budget_account_id': False + }, + } + + @api.onchange('budget_id') + def _onchange_budget_id(self): + self.ensure_one() + if self.budget_id and self.account_analytic_id: + budget_lines = self.env['crossovered.budget.lines'].search([ + ('crossovered_budget_id', '=', self.budget_id.id), + ('analytic_account_id', '=', self.account_analytic_id.id) + ]) + budget_position_ids = budget_lines.mapped('general_budget_id').ids + + return { + 'domain': { + 'budget_position_id': [('id', 'in', budget_position_ids)] + }, + 'value': { + 'budget_position_id': False, + 'budget_account_id': False + }, + } + else: + return { + 'domain': {'budget_position_id': []}, + 'value': { + 'budget_position_id': False, + 'budget_account_id': False + }, + } + + @api.onchange('budget_position_id') + def _onchange_budget_position_id(self): + # when budgetary position changes, restrict available accounts + self.ensure_one() + if self.budget_position_id: + account_ids = self.budget_position_id.account_ids.ids + return { + 'domain': { + 'budget_account_id': [('id', 'in', account_ids)]},'value': { + 'budget_account_id': False}, + } + else: + return { + 'domain': {'budget_account_id': []}, + 'value': { + 'budget_account_id': False + }, + } + + @api.constrains('budget_id', 'budget_position_id', 'account_analytic_id') + def _check_budget_position(self): + """Ensure budget position belongs to selected budget and analytic account""" + for line in self: + if line.budget_id and line.budget_position_id and line.account_analytic_id: + budget_line = self.env['crossovered.budget.lines'].search([ + ('crossovered_budget_id', '=', line.budget_id.id), + ('general_budget_id', '=', line.budget_position_id.id), + ('analytic_account_id', '=', line.account_analytic_id.id) + ], limit=1) + + if not budget_line: + raise ValidationError(_( + "The selected Budgetary Position '%s' is not linked to Budget '%s' " + "for Analytic Account '%s'") % (line.budget_position_id.name,line.budget_id.name,line.account_analytic_id.name)) + + @api.constrains('budget_position_id', 'budget_account_id') + def _check_position_account(self): + # Ensure account belongs to selected budget position + for line in self: + if line.budget_position_id and line.budget_account_id: + if line.budget_account_id not in line.budget_position_id.account_ids: + raise ValidationError(_( + "The selected Account '%s' is not included in Budgetary Position '%s'") % (line.budget_account_id.display_name,line.budget_position_id.name)) + + class PurchaseOrder(models.Model): _inherit = 'purchase.order' @@ -29,38 +158,53 @@ class PurchaseOrder(models.Model): for order in self: for rec in order.order_line: if rec.choosen: + if self.purchase_cost == 'product_line': analytic_account = rec.account_analytic_id if not analytic_account: raise ValidationError( - _("Please put cost center to the product line") + ': {}'.format(rec.product_id.name) - ) - - account_id = rec.product_id.property_account_expense_id or rec.product_id.categ_id.property_account_expense_categ_id - if not account_id: - raise ValidationError( - _("This product has no expense account") + ': {}'.format(rec.product_id.name) - ) + _("Please put cost center to the product line: %s") % rec.product_id.name) if not analytic_account: raise ValidationError(_("Analytic account not set")) - budget_lines = self.env['crossovered.budget.lines'].search([ - ('analytic_account_id', '=', analytic_account.id), - ('crossovered_budget_id.state', '=', 'done'), - ('crossovered_budget_id.date_from', '<=', self.date_order), - ('crossovered_budget_id.date_to', '>=', self.date_order) - ]) + # Determine account_id (prioritize manually selected account) + if rec.budget_account_id: + account_id = rec.budget_account_id + + else: + account_id = ( + rec.product_id.property_account_expense_id + or rec.product_id.categ_id.property_account_expense_categ_id) + + if not account_id: + raise ValidationError( + _("This product has no expense account: %s") % rec.product_id.name) + + # Get budget lines + if rec.selected_budget_line_id: + budget_lines = rec.selected_budget_line_id + else: + budget_domain = [ + ('analytic_account_id', '=', analytic_account.id), + ('crossovered_budget_id.state', '=', 'done'), + ('crossovered_budget_id.date_from', '<=', self.date_order), + ('crossovered_budget_id.date_to', '>=', self.date_order) + ] + if rec.budget_id: + budget_domain.append(('crossovered_budget_id', '=', rec.budget_id.id)) + if rec.budget_position_id: + budget_domain.append(('general_budget_id', '=', rec.budget_position_id.id)) + + budget_lines = self.env['crossovered.budget.lines'].search(budget_domain) if not budget_lines: raise ValidationError( _("No approved budget found for analytic account: %s on date: %s") - % (analytic_account.name, self.date_order) - ) + % (analytic_account.name, self.date_order)) valid_budget_positions = budget_lines.mapped('general_budget_id').filtered( - lambda x: account_id in x.account_ids - ) + lambda x: account_id in x.account_ids) if not valid_budget_positions: raise ValidationError( @@ -68,15 +212,15 @@ class PurchaseOrder(models.Model): "- Expense Account: %s\n" "- For Analytic Account: %s\n" "- Product: %s") - % (account_id.name, analytic_account.name, rec.product_id.name) - ) + % (account_id.display_name, analytic_account.name, rec.product_id.name)) + final_budget_line = False remaining_amount = rec.price_subtotal for budget_position in valid_budget_positions: candidate_lines = budget_lines.filtered( - lambda x: x.general_budget_id.id == budget_position.id - ) + lambda x: x.general_budget_id.id == budget_position.id) + for line in candidate_lines: line_remain = abs(line.remain) if line_remain <= 0: @@ -93,12 +237,14 @@ class PurchaseOrder(models.Model): 'new_balance': line_remain - used_amount, 'account_id': account_id.id })) + remaining_amount -= used_amount amount += used_amount final_budget_line = line if remaining_amount <= 0: break + if remaining_amount <= 0: break @@ -106,7 +252,7 @@ class PurchaseOrder(models.Model): raise ValidationError(_( "Not enough budget available for analytic account '%s' (Expense account: %s).\n" "Remaining unallocated amount: %.2f" - ) % (analytic_account.name, account_id.name, remaining_amount)) + ) % (analytic_account.name, account_id.display_name, remaining_amount)) if final_budget_line: self.budget_id = final_budget_line.crossovered_budget_id.id @@ -123,5 +269,5 @@ class PurchaseOrder(models.Model): 'lines_ids': confirmation_lines, 'po_id': self.id } - self.env['budget.confirmation'].with_context({}).create(data) + self.env['budget.confirmation'].create(data) self.write({'state': 'waiting'}) diff --git a/odex25_purchase/purchase_budget_fix/views/views.xml b/odex25_purchase/purchase_budget_fix/views/views.xml new file mode 100644 index 000000000..be2c16dd1 --- /dev/null +++ b/odex25_purchase/purchase_budget_fix/views/views.xml @@ -0,0 +1,42 @@ + + + + + + purchase.order.form.budget.fields + purchase.order + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file