19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:31:00 +01:00
parent a1137a1456
commit e1d89e11e3
2789 changed files with 1093187 additions and 605897 deletions

View file

@ -1,15 +1,16 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import hr_employee
from . import hr_employee_public
from . import account_move
from . import account_move_line
from . import account_payment
from . import account_tax
from . import hr_department
from . import hr_expense
from . import ir_attachment
from . import product_product
from . import product_template
from . import res_config_settings
from . import account_journal_dashboard
from . import res_company
from . import analytic
from . import ir_actions_report

View file

@ -1,77 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
from odoo.tools.misc import formatLang
from odoo.addons.account.models.account_journal_dashboard import group_by_journal
class AccountJournal(models.Model):
_inherit = "account.journal"
def _get_expenses_to_pay_query(self):
"""
Returns a tuple containing as it's first element the SQL query used to
gather the expenses in reported state data, and the arguments
dictionary to use to run it as it's second.
"""
query = """SELECT total_amount as amount_total, currency_id AS currency
FROM hr_expense_sheet
WHERE state IN ('approve', 'post')
and journal_id = %(journal_id)s"""
return (query, {'journal_id': self.id})
def get_journal_dashboard_datas(self):
res = super(AccountJournal, self).get_journal_dashboard_datas()
#add the number and sum of expenses to pay to the json defining the accounting dashboard data
(query, query_args) = self._get_expenses_to_pay_query()
self.env.cr.execute(query, query_args)
query_results_to_pay = self.env.cr.dictfetchall()
(number_to_pay, sum_to_pay) = self._count_results_and_sum_amounts(query_results_to_pay, self.company_id.currency_id)
res['number_expenses_to_pay'] = number_to_pay
res['sum_expenses_to_pay'] = formatLang(self.env, sum_to_pay or 0.0, currency_obj=self.currency_id or self.company_id.currency_id)
return res
def _prepare_expense_sheet_data_domain(self):
return [
('state', '=', 'post'),
('journal_id', 'in', self.ids),
]
def _get_expense_to_pay_query(self):
return self.env['hr.expense.sheet']._where_calc(self._prepare_expense_sheet_data_domain())
def _fill_sale_purchase_dashboard_data(self, dashboard_data):
super(AccountJournal, self)._fill_sale_purchase_dashboard_data(dashboard_data)
sale_purchase_journals = self.filtered(lambda journal: journal.type in ('sale', 'purchase'))
if not sale_purchase_journals:
return
field_list = [
"hr_expense_sheet.journal_id",
"hr_expense_sheet.total_amount AS amount_total",
"hr_expense_sheet.currency_id AS currency",
]
query, params = sale_purchase_journals._get_expense_to_pay_query().select(*field_list)
self.env.cr.execute(query, params)
query_results_to_pay = group_by_journal(self.env.cr.dictfetchall())
curr_cache = {}
for journal in sale_purchase_journals:
currency = journal.currency_id or journal.company_id.currency_id
(number_expenses_to_pay, sum_expenses_to_pay) = self._count_results_and_sum_amounts(query_results_to_pay[journal.id], currency, curr_cache=curr_cache)
dashboard_data[journal.id].update({
'number_expenses_to_pay': number_expenses_to_pay,
'sum_expenses_to_pay': currency.format(sum_expenses_to_pay),
})
def open_expenses_action(self):
action = self.env['ir.actions.act_window']._for_xml_id('hr_expense.action_hr_expense_sheet_all_all')
action['context'] = {
'search_default_approved': 1,
'search_default_to_post': 1,
'search_default_journal_id': self.id,
'default_journal_id': self.id,
}
action['view_mode'] = 'tree,form'
action['views'] = [(k,v) for k,v in action['views'] if v in ['tree', 'form']]
action['domain'] = self._prepare_expense_sheet_data_domain()
return action

View file

@ -1,101 +1,112 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from markupsafe import Markup
from odoo import models, fields, api, _
from odoo import Command, models, fields, api, _
from odoo.exceptions import ValidationError
from odoo.tools.misc import frozendict
class AccountMove(models.Model):
_inherit = "account.move"
expense_sheet_id = fields.One2many('hr.expense.sheet', 'account_move_id')
expense_ids = fields.One2many(comodel_name='hr.expense', inverse_name='account_move_id')
nb_expenses = fields.Integer(compute='_compute_nb_expenses', string='Number of Expenses', compute_sudo=True)
@api.depends('partner_id', 'expense_sheet_id', 'company_id')
def _compute_nb_expenses(self):
for move in self:
move.nb_expenses = len(move.expense_ids)
@api.depends('partner_id', 'expense_ids', 'company_id')
def _compute_commercial_partner_id(self):
own_expense_moves = self.filtered(lambda move: move.sudo().expense_sheet_id.payment_mode == 'own_account')
own_expense_moves = self.filtered(lambda move: any(expense.payment_mode == 'own_account' for expense in move.sudo().expense_ids))
for move in own_expense_moves:
if move.expense_sheet_id.payment_mode == 'own_account':
move.commercial_partner_id = (
move.partner_id.commercial_partner_id
if move.partner_id.commercial_partner_id != move.company_id.partner_id
else move.partner_id
)
move.commercial_partner_id = (
move.partner_id.commercial_partner_id
if move.partner_id.commercial_partner_id != move.company_id.partner_id
else move.partner_id
)
super(AccountMove, self - own_expense_moves)._compute_commercial_partner_id()
def action_open_expense_report(self):
@api.constrains('expense_ids')
def _check_expense_ids(self):
for move in self:
expense_payment_modes = move.expense_ids.mapped('payment_mode')
if 'company_account' in expense_payment_modes and len(move.expense_ids) > 1 :
raise ValidationError(_("Each expense paid by the company must have a distinct and dedicated journal entry."))
def action_open_expense(self):
self.ensure_one()
return {
'name': self.expense_sheet_id.name,
linked_expenses = self.expense_ids
if len(linked_expenses) > 1:
return {
'name': _("Expenses"),
'type': 'ir.actions.act_window',
'view_mode': 'list,form',
'views': [(False, 'list'), (False, 'form')],
'res_model': 'hr.expense',
'domain': [('id', 'in', linked_expenses.ids)],
}
return {
'name': linked_expenses.name,
'type': 'ir.actions.act_window',
'view_type': 'form',
'view_mode': 'form',
'res_model': 'hr.expense.sheet',
'res_id': self.expense_sheet_id.id
'views': [(False, 'form')],
'res_model': 'hr.expense',
'res_id': linked_expenses.id
}
# Expenses can be written on journal other than purchase, hence don't include them in the constraint check
def _check_journal_move_type(self):
return super(AccountMove, self.filtered(lambda x: not x.expense_sheet_id))._check_journal_move_type()
return super(AccountMove, self.filtered(lambda x: not x.expense_ids))._check_journal_move_type()
def _creation_message(self):
if self.expense_sheet_id:
return _("Expense entry Created")
if self.expense_ids:
if len(self.expense_ids) == 1:
return _("Journal entry created from this expense: %(link)s", link=self.expense_ids._get_html_link())
links = self.expense_ids[0]._get_html_link()
for additional_expense in self.expense_ids[1:]: # ', ' Destroys Markup, and each part here is safe
links += ', ' + additional_expense._get_html_link()
return _("Journal entry created from these expenses: %(links)s", links=links)
return super()._creation_message()
@api.depends('expense_sheet_id.payment_mode')
def _compute_payment_state(self):
company_paid = self.filtered(lambda m: m.expense_sheet_id.payment_mode == 'company_account')
for move in company_paid:
move.payment_state = 'paid'
super(AccountMove, self - company_paid)._compute_payment_state()
@api.depends('expense_sheet_id')
@api.depends('expense_ids')
def _compute_needed_terms(self):
# EXTENDS account
# We want to set the account destination based on the 'payment_mode'.
super()._compute_needed_terms()
for move in self:
if move.expense_sheet_id and move.expense_sheet_id.payment_mode == 'company_account':
if move.expense_ids and 'company_account' in move.expense_ids.mapped('payment_mode'):
term_lines = move.line_ids.filtered(lambda l: l.display_type != 'payment_term')
move.needed_terms = {
frozendict(
{
"move_id": move.id,
"date_maturity": move.expense_sheet_id.accounting_date
or fields.Date.context_today(move.expense_sheet_id),
"date_maturity": fields.Date.context_today(move.expense_ids),
}
): {
"balance": -sum(term_lines.mapped("balance")),
"amount_currency": -sum(term_lines.mapped("amount_currency")),
"name": "",
"account_id": move.expense_sheet_id.expense_line_ids[0]._get_expense_account_destination(),
"name": move.payment_reference or "",
"account_id": move.expense_ids._get_expense_account_destination(),
}
}
def _reverse_moves(self, default_values_list=None, cancel=False):
# Extends account
# Reversing vendor bills that represent employee reimbursements should clear them from the expense sheet such that another
# can be generated in place.
own_account_moves = self.filtered(lambda move: move.expense_sheet_id.payment_mode == 'own_account')
own_account_moves.expense_sheet_id.sudo().write({
'state': 'approve',
'account_move_id': False,
})
own_account_moves.ref = False # else, when restarting the expense flow we get duplicate issue on vendor.bill
def _prepare_product_base_line_for_taxes_computation(self, product_line):
# EXTENDS 'account'
results = super()._prepare_product_base_line_for_taxes_computation(product_line)
if product_line.expense_id.payment_mode == 'own_account':
results['special_mode'] = 'total_included'
return results
def _reverse_moves(self, default_values_list=None, cancel=False):
# EXTENDS account
self.filtered('expense_ids').write({'expense_ids': [Command.clear()]})
return super()._reverse_moves(default_values_list=default_values_list, cancel=cancel)
def unlink(self):
if self.expense_sheet_id:
self.expense_sheet_id.write({
'state': 'approve',
'account_move_id': False, # cannot change to delete='set null' in stable
})
return super().unlink()
def button_draft(self):
def button_cancel(self):
# EXTENDS account
employee_expense_sheets = self.expense_sheet_id.filtered(
lambda expense_sheet: expense_sheet.payment_mode == 'own_account'
)
employee_expense_sheets.state = 'post'
return super().button_draft()
# We need to override this method to remove the link with the move, else we cannot reimburse them anymore.
# And cancelling the move != cancelling the expense
res = super().button_cancel()
self.filtered('expense_ids').write({'expense_ids': [Command.clear()]})
return res

View file

@ -1,62 +1,42 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo.tools.misc import frozendict
from odoo.tools import SQL
class AccountMoveLine(models.Model):
_inherit = "account.move.line"
expense_id = fields.Many2one('hr.expense', string='Expense', copy=True)
expense_id = fields.Many2one('hr.expense', string='Expense', copy=True, index='btree_not_null') # copy=True, else we don't know price is tax incl.
def _compute_partner_id(self):
# EXTENDS account to ensure the partner is correctly set on all the move lines, preventing wrong bank accounts on payments
expense_lines = self.filtered('move_id.expense_ids') # Can't use expense_id because the payment terms line may not have it set
super(AccountMoveLine, self - expense_lines)._compute_partner_id()
for line in expense_lines:
line.partner_id = line.move_id.partner_id # The employee partner is correctly set on the move
@api.constrains('account_id', 'display_type')
def _check_payable_receivable(self):
super(AccountMoveLine, self.filtered(lambda line: line.move_id.expense_sheet_id.payment_mode != 'company_account'))._check_payable_receivable()
def reconcile(self):
# OVERRIDE
not_paid_expenses = self.move_id.expense_sheet_id.expense_line_ids.filtered(lambda expense: expense.state != 'done')
res = super().reconcile()
# Do not update expense or expense sheet states when reversing journal entries
not_paid_expense_sheets = not_paid_expenses.sheet_id.filtered(lambda sheet: sheet.account_move_id.payment_state != 'reversed')
paid_expenses = not_paid_expenses.filtered(lambda expense: expense.currency_id.is_zero(expense.amount_residual))
paid_expenses.write({'state': 'done'})
not_paid_expense_sheets.filtered(lambda sheet: all(expense.state == 'done' for expense in sheet.expense_line_ids)).set_to_paid()
return res
super(AccountMoveLine, self.filtered(lambda line: line.expense_id.payment_mode != 'company_account'))._check_payable_receivable()
def _get_attachment_domains(self):
attachment_domains = super(AccountMoveLine, self)._get_attachment_domains()
if self.expense_id:
attachment_domains.append([('res_model', '=', 'hr.expense'), ('res_id', '=', self.expense_id.id)])
attachment_domains.append([('res_model', '=', 'hr.expense'), ('res_id', 'in', self.expense_id.ids)])
return attachment_domains
def _compute_tax_key(self):
super()._compute_tax_key()
for line in self:
if line.expense_id:
line.tax_key = frozendict(**line.tax_key, expense_id=line.expense_id.id)
def _compute_all_tax(self):
expense_lines = self.filtered('expense_id')
super(AccountMoveLine, expense_lines.with_context(force_price_include=True))._compute_all_tax()
super(AccountMoveLine, self - expense_lines)._compute_all_tax()
for line in expense_lines:
for key in list(line.compute_all_tax.keys()):
new_key = frozendict(**key, expense_id=line.expense_id.id)
line.compute_all_tax[new_key] = line.compute_all_tax.pop(key)
@api.model
def _get_attachment_by_record(self, id_model2attachments, move_line):
return (
super()._get_attachment_by_record(id_model2attachments, move_line)
or id_model2attachments.get(('hr.expense', move_line.expense_id.id))
)
def _compute_totals(self):
expenses = self.filtered('expense_id')
super(AccountMoveLine, expenses.with_context(force_price_include=True))._compute_totals()
super(AccountMoveLine, self - expenses)._compute_totals()
def _convert_to_tax_base_line_dict(self):
result = super()._convert_to_tax_base_line_dict()
if self.expense_id:
result.setdefault('extra_context', {})
result['extra_context']['force_price_include'] = True
return result
def _get_extra_query_base_tax_line_mapping(self):
return ' AND (base_line.expense_id IS NULL OR account_move_line.expense_id = base_line.expense_id)'
def _get_extra_query_base_tax_line_mapping(self) -> SQL:
return SQL(' AND (base_line.expense_id IS NULL OR account_move_line.expense_id = base_line.expense_id)')

View file

@ -1,60 +1,51 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, _
from odoo.exceptions import UserError
from odoo import api, models, fields, _
from odoo.exceptions import UserError, ValidationError
class AccountPayment(models.Model):
_inherit = "account.payment"
def action_cancel(self):
expense_ids = fields.One2many(related='move_id.expense_ids')
def _compute_outstanding_account_id(self):
# EXTENDS account
for payment in self:
if payment.expense_sheet_id.payment_mode != 'own_account':
continue
payment.with_context(skip_account_move_synchronization=True).expense_sheet_id.write({
'state': 'approve',
'account_move_id': False,
})
expense_company_payments = self.filtered(lambda payment: payment.expense_ids.payment_mode == 'company_account')
for payment in expense_company_payments:
payment.outstanding_account_id = payment.expense_ids._get_expense_account_destination()
super(AccountPayment, self - expense_company_payments)._compute_outstanding_account_id()
return super().action_cancel()
def _compute_show_require_partner_bank(self):
expense_payments = self.filtered(lambda pay: pay.move_id.expense_ids)
super()._compute_show_require_partner_bank()
expense_payments.require_partner_bank_account = False
def action_draft(self):
employee_expense_sheets = self.reconciled_bill_ids.expense_sheet_id.filtered(
lambda expense_sheet: expense_sheet.payment_mode == 'own_account'
)
employee_expense_sheets.state = 'post'
return super().action_draft()
def write(self, vals):
trigger_fields = {
'date', 'amount', 'payment_type', 'partner_type', 'payment_reference',
'currency_id', 'partner_id', 'destination_account_id', 'partner_bank_id', 'journal_id'
'ref', 'payment_method_line_id'
}
if self.expense_ids and any(field_name in trigger_fields for field_name in vals):
raise UserError(_("You cannot do this modification since the payment is linked to an expense."))
return super().write(vals)
def action_open_expense_report(self):
def action_open_expense(self):
self.ensure_one()
return {
'name': self.expense_sheet_id.name,
'name': self.expense_ids.name,
'type': 'ir.actions.act_window',
'view_type': 'form',
'view_mode': 'form',
'views': [(False, 'form')],
'res_model': 'hr.expense.sheet',
'res_id': self.expense_sheet_id.id
'res_model': 'hr.expense',
'res_id': self.expense_ids.id,
}
def _synchronize_from_moves(self, changed_fields):
# EXTENDS account
if self.expense_sheet_id:
# Constraints bypass when entry is linked to an expense.
# Context is not enough, as we want to be able to delete
# and update those entries later on.
return
return super()._synchronize_from_moves(changed_fields)
def _synchronize_to_moves(self, changed_fields):
# EXTENDS account
if self.expense_sheet_id:
raise UserError(_("You cannot do this modification since the payment is linked to an expense report."))
return super()._synchronize_to_moves(changed_fields)
def _creation_message(self):
# EXTENDS mail
self.ensure_one()
if self.move_id.expense_sheet_id:
return _("Payment created for: %s", self.move_id.expense_sheet_id._get_html_link())
if self.move_id.expense_ids:
return _("Payment created for: %s", self.move_id.expense_ids._get_html_link())
return super()._creation_message()

View file

@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
from odoo import models
class AccountTax(models.Model):
_inherit = "account.tax"
def _hook_compute_is_used(self, taxes_to_compute):
# OVERRIDE in order to fetch taxes used in expenses
used_taxes = super()._hook_compute_is_used(taxes_to_compute)
taxes_to_compute -= used_taxes
if taxes_to_compute:
self.env['hr.expense'].flush_model(['tax_ids'])
self.env.cr.execute("""
SELECT id
FROM account_tax
WHERE EXISTS(
SELECT 1
FROM expense_tax AS exp
WHERE tax_id IN %s
AND account_tax.id = exp.tax_id
)
""", [tuple(taxes_to_compute)])
used_taxes.update([tax[0] for tax in self.env.cr.fetchall()])
return used_taxes
def _prepare_base_line_for_taxes_computation(self, record, **kwargs):
# EXTENDS 'account'
results = super()._prepare_base_line_for_taxes_computation(record, **kwargs)
results['expense_id'] = self._get_base_line_field_value_from_record(record, 'expense_id', kwargs, self.env['hr.expense'])
return results
def _prepare_tax_line_for_taxes_computation(self, record, **kwargs):
# EXTENDS 'account'
results = super()._prepare_tax_line_for_taxes_computation(record, **kwargs)
results['expense_id'] = self._get_base_line_field_value_from_record(record, 'expense_id', kwargs, self.env['hr.expense'])
return results
def _prepare_base_line_grouping_key(self, base_line):
# EXTENDS 'account'
results = super()._prepare_base_line_grouping_key(base_line)
results['expense_id'] = base_line['expense_id'].id
return results
def _prepare_tax_line_repartition_grouping_key(self, tax_line):
# EXTENDS 'account'
results = super()._prepare_tax_line_repartition_grouping_key(tax_line)
results['expense_id'] = tax_line['expense_id'].id
return results

View file

@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.tools import SQL
from odoo.exceptions import UserError
@ -16,17 +17,29 @@ class AccountAnalyticApplicability(models.Model):
ondelete={'expense': 'cascade'},
)
@api.depends('business_domain')
def _compute_display_account_prefix(self):
super()._compute_display_account_prefix()
for applicability in self.filtered(lambda rec: rec.business_domain == 'expense'):
applicability.display_account_prefix = True
class AnalyticAccount(models.Model):
class AccountAnalyticAccount(models.Model):
_inherit = 'account.analytic.account'
@api.ondelete(at_uninstall=False)
def _unlink_except_account_in_analytic_distribution(self):
self.env.cr.execute("""
SELECT id FROM hr_expense
WHERE analytic_distribution::jsonb ?| array[%s]
LIMIT 1
""", ([str(id) for id in self.ids],))
self.env.cr.execute(
SQL(
r"""
SELECT id FROM hr_expense
WHERE %s && %s
LIMIT 1
""",
[str(account_id) for account_id in self.ids],
self.env['hr.expense']._query_analytic_accounts(),
)
)
expense_ids = self.env.cr.fetchall()
if expense_ids:
raise UserError(_("You cannot delete an analytic account that is used in an expense."))

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
@ -7,10 +6,11 @@ from odoo import fields, models
class HrDepartment(models.Model):
_inherit = 'hr.department'
def _compute_expense_sheets_to_approve(self):
expense_sheet_data = self.env['hr.expense.sheet']._read_group([('department_id', 'in', self.ids), ('state', '=', 'submit')], ['department_id'], ['department_id'])
result = dict((data['department_id'][0], data['department_id_count']) for data in expense_sheet_data)
for department in self:
department.expense_sheets_to_approve_count = result.get(department.id, 0)
expenses_to_approve_count = fields.Integer(compute='_compute_expenses_to_approve_count', string='Expenses to Approve')
def _compute_expenses_to_approve_count(self):
expense_data = self.env['hr.expense']._read_group([('department_id', 'in', self.ids), ('state', '=', 'submitted')], ['department_id'], ['__count'])
result = {department.id: count for department, count in expense_data}
for department in self:
department.expenses_to_approve_count = result.get(department.id, 0)
expense_sheets_to_approve_count = fields.Integer(compute='_compute_expense_sheets_to_approve', string='Expenses Reports to Approve')

View file

@ -1,36 +1,62 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, api
from odoo.fields import Domain
class Employee(models.Model):
class HrEmployee(models.Model):
_inherit = 'hr.employee'
def _group_hr_expense_user_domain(self):
# We return the domain only if the group exists for the following reason:
# When a group is created (at module installation), the `res.users` form view is
# automatically modifiedto add application accesses. When modifiying the view, it
# automatically modified to add application accesses. When modifying the view, it
# reads the related field `expense_manager_id` of `res.users` and retrieve its domain.
# This is a problem because the `group_hr_expense_user` record has already been created but
# This is a problem because the `group_hr_expense_team_approver` record has already been created but
# not its associated `ir.model.data` which makes `self.env.ref(...)` fail.
group = self.env.ref('hr_expense.group_hr_expense_team_approver', raise_if_not_found=False)
return [('groups_id', 'in', group.ids)] if group else []
return [
'|', ('id', 'parent_of', self.ids), ('all_group_ids', 'in', group.ids)
] if group else [('id', 'parent_of', self.ids)]
expense_manager_id = fields.Many2one(
'res.users', string='Expense',
domain=_group_hr_expense_user_domain,
comodel_name='res.users',
string='Expense Approver',
compute='_compute_expense_manager', store=True, readonly=False,
domain=_group_hr_expense_user_domain,
help='Select the user responsible for approving "Expenses" of this employee.\n'
'If empty, the approval is done by an Administrator or Approver (determined in settings/users).')
'If empty, the approval is done by an Administrator or Approver (determined in settings/users).',
)
filter_for_expense = fields.Boolean(store=False, search='_search_filter_for_expense', groups="hr.group_hr_user")
def _search_filter_for_expense(self, operator, value):
if operator != 'in':
return NotImplemented
domain = Domain.FALSE # Nothing accepted by domain, by default
user = self.env.user
employee = user.employee_id
if user.has_groups('hr_expense.group_hr_expense_user'):
domain = Domain('company_id', '=', False) | Domain('company_id', 'child_of', self.env.company.root_id.id) # Then, domain accepts everything
elif user.has_groups('hr_expense.group_hr_expense_team_approver') and user.employee_ids:
domain = (
Domain('department_id.manager_id', '=', employee.id)
| Domain('parent_id', '=', employee.id)
| Domain('id', '=', employee.id)
| Domain('expense_manager_id', '=', user.id)
) & Domain('company_id', 'in', [False, employee.company_id.id])
elif user.employee_id:
domain = Domain('id', '=', employee.id) & Domain('company_id', 'in', [False, employee.company_id.id])
return domain
@api.depends('parent_id')
def _compute_expense_manager(self):
for employee in self:
previous_manager = employee._origin.parent_id.user_id
manager = employee.parent_id.user_id
if manager and manager.has_group('hr_expense.group_hr_expense_user') and (employee.expense_manager_id == previous_manager or not employee.expense_manager_id):
employee.expense_manager_id = manager
new_manager = employee.parent_id.user_id
if new_manager and (employee.expense_manager_id == previous_manager or not employee.expense_manager_id):
employee.expense_manager_id = new_manager
elif not employee.expense_manager_id:
employee.expense_manager_id = False
@ -38,17 +64,7 @@ class Employee(models.Model):
return super()._get_user_m2o_to_empty_on_archived_employees() + ['expense_manager_id']
class EmployeePublic(models.Model):
class HrEmployeePublic(models.Model):
_inherit = 'hr.employee.public'
expense_manager_id = fields.Many2one('res.users', readonly=True)
class User(models.Model):
_inherit = ['res.users']
expense_manager_id = fields.Many2one(related='employee_id.expense_manager_id', readonly=False)
@property
def SELF_READABLE_FIELDS(self):
return super().SELF_READABLE_FIELDS + ['expense_manager_id']

View file

@ -0,0 +1,28 @@
from odoo import fields, models
from odoo.fields import Domain
class HrEmployee(models.Model):
_inherit = 'hr.employee.public'
filter_for_expense = fields.Boolean(store=False, search='_search_filter_for_expense', groups="hr.group_hr_user")
def _search_filter_for_expense(self, operator, value):
if operator != 'in':
return NotImplemented
domain = Domain.FALSE # Nothing accepted by domain, by default
user = self.env.user
employee = user.employee_id
if user.has_groups('hr_expense.group_hr_expense_user') or user.has_groups('account.group_account_user'):
domain = Domain('company_id', '=', False) | Domain('company_id', 'child_of', self.env.company.root_id.id) # Then, domain accepts everything
elif user.has_groups('hr_expense.group_hr_expense_team_approver') and user.employee_ids:
domain = (
Domain('department_id.manager_id', '=', employee.id)
| Domain('parent_id', '=', employee.id)
| Domain('id', '=', employee.id)
| Domain('expense_manager_id', '=', user.id)
) & Domain('company_id', 'in', [False, employee.company_id.id])
elif user.employee_id:
domain = Domain('id', '=', employee.id) & Domain('company_id', 'in', [False, employee.company_id.id])
return domain

View file

@ -0,0 +1,54 @@
import io
from odoo import models, _
from odoo.tools import pdf
from odoo.tools.pdf import OdooPdfFileReader, OdooPdfFileWriter, PdfReadError, DependencyError
class IrActionsReport(models.Model):
_inherit = 'ir.actions.report'
def _render_qweb_pdf_prepare_streams(self, report_ref, data, res_ids=None):
# OVERRIDE
res = super()._render_qweb_pdf_prepare_streams(report_ref, data, res_ids)
if not res_ids:
return res
report = self._get_report(report_ref)
if report.report_name == 'hr_expense.report_expense':
for expense in self.env['hr.expense'].browse(res_ids):
# Will contains the expense
stream_list = []
stream = res[expense.id]['stream']
stream_list.append(stream)
attachments = self.env['ir.attachment'].search([('res_id', 'in', expense.ids), ('res_model', '=', 'hr.expense')])
expense_report = OdooPdfFileReader(stream, strict=False)
output_pdf = OdooPdfFileWriter()
output_pdf.appendPagesFromReader(expense_report)
for attachment in self._prepare_local_attachments(attachments):
if attachment.mimetype == 'application/pdf':
attachment_stream = pdf.to_pdf_stream(attachment)
else:
# In case the attachment is not a pdf we will create a new PDF from the template "report_expense_img"
# And then append to the stream. By doing so, the attachment is put on a new page with the name of the expense
# associated to the attachment
data['attachment'] = attachment
attachment_prep_stream = self._render_qweb_pdf_prepare_streams('hr_expense.report_expense_img', data, res_ids=res_ids)
attachment_stream = attachment_prep_stream[expense.id]['stream']
attachment_reader = OdooPdfFileReader(attachment_stream, strict=False)
try:
output_pdf.appendPagesFromReader(attachment_reader)
except (PdfReadError, DependencyError) as e:
expense._message_log(body=_(
"The attachment (%(attachment_name)s) has not been added to the report due to the following error: '%(error)s'",
attachment_name=attachment.name,
error=e
))
continue
stream_list.append(attachment_stream)
new_pdf_stream = io.BytesIO()
output_pdf.write(new_pdf_stream)
res[expense.id]['stream'] = new_pdf_stream
for stream in stream_list:
stream.close()
return res

View file

@ -1,39 +0,0 @@
from odoo import models, api
class IrAttachment(models.Model):
_inherit = 'ir.attachment'
@api.model_create_multi
def create(self, vals_list):
attachments = super().create(vals_list)
if self.env.context.get('sync_attachment', True):
expenses_attachments = attachments.filtered(lambda att: att.res_model == 'hr.expense')
if expenses_attachments:
expenses = self.env['hr.expense'].browse(expenses_attachments.mapped('res_id'))
for expense in expenses.filtered('sheet_id'):
checksums = set(expense.sheet_id.attachment_ids.mapped('checksum'))
for attachment in expense.attachment_ids.filtered(lambda att: att.checksum not in checksums):
attachment.copy({
'res_model': 'hr.expense.sheet',
'res_id': expense.sheet_id.id,
})
return attachments
def unlink(self):
if self.env.context.get('sync_attachment', True):
attachments_to_unlink = self.env['ir.attachment']
expenses_attachments = self.filtered(lambda att: att.res_model == 'hr.expense')
if expenses_attachments:
expenses = self.env['hr.expense'].browse(expenses_attachments.mapped('res_id'))
for expense in expenses.exists().filtered('sheet_id'):
checksums = set(expense.attachment_ids.mapped('checksum'))
attachments_to_unlink += expense.sheet_id.attachment_ids.filtered(lambda att: att.checksum in checksums)
sheets_attachments = self.filtered(lambda att: att.res_model == 'hr.expense.sheet')
if sheets_attachments:
sheets = self.env['hr.expense.sheet'].browse(sheets_attachments.mapped('res_id'))
for sheet in sheets.exists():
checksums = set((sheet.attachment_ids & sheets_attachments).mapped('checksum'))
attachments_to_unlink += sheet.expense_line_ids.attachment_ids.filtered(lambda att: att.checksum in checksums)
super(IrAttachment, attachments_to_unlink).unlink()
return super().unlink()

View file

@ -10,19 +10,46 @@ class ProductProduct(models.Model):
def _compute_standard_price_update_warning(self):
undone_expenses = self.env['hr.expense']._read_group(
domain=[('state', '=', 'draft'), ('product_id', 'in', self.ids)],
fields=['unit_amount:array_agg'],
groupby=['product_id'],
groupby=['price_unit'],
)
mapp = {row['product_id'][0]: row['unit_amount'] for row in undone_expenses}
# The following list is composed of all the price_units of expenses that use this product and should NOT trigger a warning.
# Those are the amounts of any undone expense using this product and 0.0 which is the default unit_amount.
unit_amounts_no_warning = [self.env.company.currency_id.round(row[0]) for row in undone_expenses]
for product in self:
product.standard_price_update_warning = False
if product._origin.id in mapp:
# The following list is composed of all the unit_amounts of expenses that use this product and should NOT trigger a warning.
# Those are the amounts of any undone expense using this product and 0.0 which is the default unit_amount.
unit_amounts_no_warning = {float(unit_amount) for unit_amount in mapp[product._origin.id]}
if undone_expenses:
rounded_price = self.env.company.currency_id.round(product.standard_price)
if rounded_price and (len(unit_amounts_no_warning) > 1 or (len(unit_amounts_no_warning) == 1 and rounded_price not in unit_amounts_no_warning)):
product.standard_price_update_warning = _(
"There are unsubmitted expenses linked to this category. Updating the category cost will change expense amounts. "
"Make sure it is what you want to do."
)
def write(self, vals):
result = super().write(vals)
if 'standard_price' in vals:
expenses_sudo = self.env['hr.expense'].sudo().search([
('company_id', '=', self.env.company.id),
('product_id', 'in', self.ids),
('state', '=', 'draft'),
])
for expense_sudo in expenses_sudo:
expense_product_sudo = expense_sudo.product_id
product_has_cost = (
expense_product_sudo
and not expense_sudo.company_currency_id.is_zero(expense_product_sudo.standard_price)
)
expense_vals = {
'product_has_cost': product_has_cost,
}
if product_has_cost:
expense_vals.update({
'price_unit': expense_product_sudo.standard_price,
})
else:
expense_vals.update({
'quantity': 1,
'price_unit': expense_sudo.total_amount
})
expense_sudo.write(expense_vals)
return result

View file

@ -15,7 +15,7 @@ class ProductTemplate(models.Model):
result['supplier_taxes_id'] = False
return result
can_be_expensed = fields.Boolean(string="Can be Expensed", compute='_compute_can_be_expensed',
can_be_expensed = fields.Boolean(string="Expenses", compute='_compute_can_be_expensed',
store=True, readonly=False, help="Specify whether the product can be selected in an expense.")
def _auto_init(self):
@ -30,6 +30,12 @@ class ProductTemplate(models.Model):
)
return super()._auto_init()
@api.depends('type')
@api.depends('type', 'purchase_ok')
def _compute_can_be_expensed(self):
self.filtered(lambda p: p.type not in ['consu', 'service']).update({'can_be_expensed': False})
self.filtered(lambda p: p.type not in ['consu', 'service'] or not p.purchase_ok).update({'can_be_expensed': False})
@api.depends('can_be_expensed')
def _compute_purchase_ok(self):
for record in self:
if record.can_be_expensed:
record.purchase_ok = True

View file

@ -10,13 +10,12 @@ class ResCompany(models.Model):
"account.journal",
string="Default Expense Journal",
check_company=True,
domain="[('type', '=', 'purchase'), ('company_id', '=', company_id)]",
domain="[('type', '=', 'purchase')]",
help="The company's default journal used when an employee expense is created.",
)
company_expense_journal_id = fields.Many2one(
"account.journal",
string="Default Company Expense Journal",
company_expense_allowed_payment_method_line_ids = fields.Many2many(
"account.payment.method.line",
string="Payment methods available for expenses paid by company",
check_company=True,
domain="[('type', 'in', ['cash', 'bank']), ('company_id', '=', company_id)]",
help="The company's default journal used when a company expense is created.",
domain="[('payment_type', '=', 'outbound'), ('journal_id', '!=', False), ('journal_id.active', '=', True)]",
)

View file

@ -1,38 +1,73 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
expense_alias_prefix = fields.Char(
hr_expense_alias_prefix = fields.Char(
'Default Alias Name for Expenses',
compute='_compute_expense_alias_prefix',
compute='_compute_hr_expense_alias_prefix',
store=True,
readonly=False)
use_mailgateway = fields.Boolean(string='Let your employees record expenses by email',
config_parameter='hr_expense.use_mailgateway')
hr_expense_alias_domain_id = fields.Many2one(
comodel_name='mail.alias.domain',
compute='_compute_hr_expense_alias_domain_id',
inverse='_inverse_hr_expense_alias_domain_id',
readonly=False)
hr_expense_use_mailgateway = fields.Boolean(string='Let your employees record expenses by email',
config_parameter='hr_expense.use_mailgateway')
module_hr_payroll_expense = fields.Boolean(string='Reimburse Expenses in Payslip')
module_hr_expense_extract = fields.Boolean(string='Send bills to OCR to generate expenses')
expense_journal_id = fields.Many2one('account.journal', related='company_id.expense_journal_id', readonly=False)
company_expense_journal_id = fields.Many2one('account.journal', related='company_id.company_expense_journal_id', readonly=False)
module_hr_expense_stripe = fields.Boolean(string='Link your stripe issuing account to manage company credit cards for your employees through Odoo')
expense_journal_id = fields.Many2one('account.journal', related='company_id.expense_journal_id', readonly=False, check_company=True, domain="[('type', '=', 'purchase')]")
company_expense_allowed_payment_method_line_ids = fields.Many2many(
comodel_name='account.payment.method.line',
check_company=True,
related='company_id.company_expense_allowed_payment_method_line_ids',
readonly=False,
)
@api.model
def get_values(self):
res = super(ResConfigSettings, self).get_values()
expense_alias = self.env.ref('hr_expense.mail_alias_expense', raise_if_not_found=False)
res.update(
expense_alias_prefix=self.env.ref('hr_expense.mail_alias_expense').alias_name,
hr_expense_alias_prefix=expense_alias.alias_name if expense_alias else False,
hr_expense_alias_domain_id=expense_alias.alias_domain_id if expense_alias else False,
)
return res
def set_values(self):
super().set_values()
alias = self.env.ref('hr_expense.mail_alias_expense', raise_if_not_found=False)
if alias and alias.alias_name != self.expense_alias_prefix:
alias.alias_name = self.expense_alias_prefix
expense_alias = self.env.ref('hr_expense.mail_alias_expense', raise_if_not_found=False)
if not expense_alias and self.hr_expense_alias_prefix:
# create data again
alias = self.env['mail.alias'].sudo().create({
'alias_contact': 'employees',
'alias_domain_id': self.env.company.alias_domain_id.id,
'alias_model_id': self.env['ir.model']._get_id('hr.expense'),
'alias_name': self.hr_expense_alias_prefix,
})
self.env['ir.model.data'].sudo().create({
'name': 'mail_alias_expense',
'module': 'hr_expense',
'model': 'mail.alias',
'noupdate': True,
'res_id': alias.id,
})
elif expense_alias and expense_alias.alias_name != self.hr_expense_alias_prefix:
expense_alias.alias_name = self.hr_expense_alias_prefix
@api.depends('use_mailgateway')
def _compute_expense_alias_prefix(self):
self.filtered(lambda w: not w.use_mailgateway).update({'expense_alias_prefix': False})
@api.depends('hr_expense_use_mailgateway')
def _compute_hr_expense_alias_prefix(self):
self.filtered(lambda w: not w.hr_expense_use_mailgateway).hr_expense_alias_prefix = False
@api.depends('hr_expense_use_mailgateway')
def _compute_hr_expense_alias_domain_id(self):
self.filtered(lambda w: not w.hr_expense_use_mailgateway).hr_expense_alias_domain_id = False
def _inverse_hr_expense_alias_domain_id(self):
expense_alias = self.env.ref('hr_expense.mail_alias_expense', raise_if_not_found=False)
for record in self:
if expense_alias and expense_alias.alias_domain_id != record.hr_expense_alias_domain_id:
expense_alias.alias_domain_id = record.hr_expense_alias_domain_id