Initial commit: Hr packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:50 +02:00
commit 62531cd146
2820 changed files with 1432848 additions and 0 deletions

View file

@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
from . import hr_employee
from . import account_move
from . import account_move_line
from . import account_payment
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

View file

@ -0,0 +1,77 @@
# -*- 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

@ -0,0 +1,101 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields, api, _
from odoo.tools.misc import frozendict
class AccountMove(models.Model):
_inherit = "account.move"
expense_sheet_id = fields.One2many('hr.expense.sheet', 'account_move_id')
@api.depends('partner_id', 'expense_sheet_id', 'company_id')
def _compute_commercial_partner_id(self):
own_expense_moves = self.filtered(lambda move: move.sudo().expense_sheet_id.payment_mode == 'own_account')
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
)
super(AccountMove, self - own_expense_moves)._compute_commercial_partner_id()
def action_open_expense_report(self):
self.ensure_one()
return {
'name': self.expense_sheet_id.name,
'type': 'ir.actions.act_window',
'view_type': 'form',
'view_mode': 'form',
'res_model': 'hr.expense.sheet',
'res_id': self.expense_sheet_id.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()
def _creation_message(self):
if self.expense_sheet_id:
return _("Expense entry Created")
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')
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':
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),
}
): {
"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(),
}
}
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
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):
# 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()

View file

@ -0,0 +1,62 @@
# -*- 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
class AccountMoveLine(models.Model):
_inherit = "account.move.line"
expense_id = fields.Many2one('hr.expense', string='Expense', copy=True)
@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
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)])
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)
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)'

View file

@ -0,0 +1,60 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, _
from odoo.exceptions import UserError
class AccountPayment(models.Model):
_inherit = "account.payment"
def action_cancel(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,
})
return super().action_cancel()
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 action_open_expense_report(self):
self.ensure_one()
return {
'name': self.expense_sheet_id.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
}
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())
return super()._creation_message()

View file

@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class AccountAnalyticApplicability(models.Model):
_inherit = 'account.analytic.applicability'
_description = "Analytic Plan's Applicabilities"
business_domain = fields.Selection(
selection_add=[
('expense', 'Expense'),
],
ondelete={'expense': 'cascade'},
)
class AnalyticAccount(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],))
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

@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
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)
expense_sheets_to_approve_count = fields.Integer(compute='_compute_expense_sheets_to_approve', string='Expenses Reports to Approve')

View file

@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, api
class Employee(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
# 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
# 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 []
expense_manager_id = fields.Many2one(
'res.users', string='Expense',
domain=_group_hr_expense_user_domain,
compute='_compute_expense_manager', store=True, readonly=False,
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).')
@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
elif not employee.expense_manager_id:
employee.expense_manager_id = False
def _get_user_m2o_to_empty_on_archived_employees(self):
return super()._get_user_m2o_to_empty_on_archived_employees() + ['expense_manager_id']
class EmployeePublic(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']

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,39 @@
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

@ -0,0 +1,28 @@
from odoo import api, fields, models, _
class ProductProduct(models.Model):
_inherit = "product.product"
standard_price_update_warning = fields.Char(compute="_compute_standard_price_update_warning")
@api.onchange('standard_price')
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'],
)
mapp = {row['product_id'][0]: row['unit_amount'] 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]}
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."
)

View file

@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo.tools.sql import column_exists, create_column
class ProductTemplate(models.Model):
_inherit = "product.template"
@api.model
def default_get(self, fields):
result = super(ProductTemplate, self).default_get(fields)
if self.env.context.get('default_can_be_expensed'):
result['supplier_taxes_id'] = False
return result
can_be_expensed = fields.Boolean(string="Can be Expensed", compute='_compute_can_be_expensed',
store=True, readonly=False, help="Specify whether the product can be selected in an expense.")
def _auto_init(self):
if not column_exists(self.env.cr, "product_template", "can_be_expensed"):
create_column(self.env.cr, "product_template", "can_be_expensed", "boolean")
self.env.cr.execute(
"""
UPDATE product_template
SET can_be_expensed = false
WHERE type NOT IN ('consu', 'service')
"""
)
return super()._auto_init()
@api.depends('type')
def _compute_can_be_expensed(self):
self.filtered(lambda p: p.type not in ['consu', 'service']).update({'can_be_expensed': False})

View file

@ -0,0 +1,22 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class ResCompany(models.Model):
_inherit = "res.company"
expense_journal_id = fields.Many2one(
"account.journal",
string="Default Expense Journal",
check_company=True,
domain="[('type', '=', 'purchase'), ('company_id', '=', company_id)]",
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",
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.",
)

View file

@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
expense_alias_prefix = fields.Char(
'Default Alias Name for Expenses',
compute='_compute_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')
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)
@api.model
def get_values(self):
res = super(ResConfigSettings, self).get_values()
res.update(
expense_alias_prefix=self.env.ref('hr_expense.mail_alias_expense').alias_name,
)
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
@api.depends('use_mailgateway')
def _compute_expense_alias_prefix(self):
self.filtered(lambda w: not w.use_mailgateway).update({'expense_alias_prefix': False})