diff --git a/addons/hr_expense/data/hr_expense_demo.xml b/addons/hr_expense/data/hr_expense_demo.xml
index a97ed534ea7..bdc3f1ff19a 100644
--- a/addons/hr_expense/data/hr_expense_demo.xml
+++ b/addons/hr_expense/data/hr_expense_demo.xml
@@ -36,7 +36,7 @@
The first word of the email subject did not correspond to any category code. You'll have to set the category manually on the expense.
- Price:
diff --git a/addons/hr_expense/models/__init__.py b/addons/hr_expense/models/__init__.py
index 552fce4c25a..85fd0506c63 100644
--- a/addons/hr_expense/models/__init__.py
+++ b/addons/hr_expense/models/__init__.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import hr_employee
@@ -8,6 +7,7 @@ from . import account_payment
from . import account_tax
from . import hr_department
from . import hr_expense
+from . import hr_expense_sheet
from . import product_template
from . import res_config_settings
from . import account_journal_dashboard
diff --git a/addons/hr_expense/models/account_journal_dashboard.py b/addons/hr_expense/models/account_journal_dashboard.py
index 327527289e5..042ea15d90d 100644
--- a/addons/hr_expense/models/account_journal_dashboard.py
+++ b/addons/hr_expense/models/account_journal_dashboard.py
@@ -1,8 +1,6 @@
-# -*- 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 import models
class AccountJournal(models.Model):
diff --git a/addons/hr_expense/models/account_move.py b/addons/hr_expense/models/account_move.py
index 71acedefc42..6f5142a9521 100644
--- a/addons/hr_expense/models/account_move.py
+++ b/addons/hr_expense/models/account_move.py
@@ -10,7 +10,7 @@ from odoo.tools.misc import frozendict
class AccountMove(models.Model):
_inherit = "account.move"
- expense_sheet_id = fields.Many2one('hr.expense.sheet', ondelete='set null', copy=False)
+ expense_sheet_id = fields.Many2one(comodel_name='hr.expense.sheet', ondelete='set null', copy=False)
def action_open_expense_report(self):
self.ensure_one()
@@ -44,8 +44,7 @@ class AccountMove(models.Model):
frozendict(
{
"move_id": move.id,
- "date_maturity": move.expense_sheet_id.accounting_date
- or fields.Date.context_today(move.expense_sheet_id),
+ "date_maturity": move.expense_sheet_id.accounting_date or fields.Date.context_today(move.expense_sheet_id),
}
): {
"balance": -sum(term_lines.mapped("balance")),
diff --git a/addons/hr_expense/models/hr_employee.py b/addons/hr_expense/models/hr_employee.py
index 7a370759410..67a09d82e63 100644
--- a/addons/hr_expense/models/hr_employee.py
+++ b/addons/hr_expense/models/hr_employee.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, api
@@ -10,7 +9,7 @@ class Employee(models.Model):
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
# not its associated `ir.model.data` which makes `self.env.ref(...)` fail.
@@ -18,11 +17,13 @@ class Employee(models.Model):
return [('groups_id', 'in', group.ids)] if group else []
expense_manager_id = fields.Many2one(
- 'res.users', string='Expense',
- domain=_group_hr_expense_user_domain,
+ comodel_name='res.users',
+ string='Expense',
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')
@@ -31,7 +32,8 @@ class Employee(models.Model):
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):
+ 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
@@ -43,7 +45,7 @@ class Employee(models.Model):
assert operator == '='
assert value
- res = [('id', '=', 0)] # Nothing accepted by domain, by default
+ res = [('id', '=', 0)] # Nothing accepted by domain, by default
if self.user_has_groups('hr_expense.group_hr_expense_user') or self.user_has_groups('account.group_account_user'):
res = ['|', ('company_id', '=', False), ('company_id', 'child_of', self.env.company.root_id.id)] # Then, domain accepts everything
elif self.user_has_groups('hr_expense.group_hr_expense_team_approver') and self.env.user.employee_ids:
@@ -62,6 +64,7 @@ class Employee(models.Model):
res = [('id', '=', employee.id), '|', ('company_id', '=', False), ('company_id', '=', employee.company_id.id)]
return res
+
class EmployeePublic(models.Model):
_inherit = 'hr.employee.public'
diff --git a/addons/hr_expense/models/hr_expense.py b/addons/hr_expense/models/hr_expense.py
index b810a4d8666..cae3ae21f5c 100644
--- a/addons/hr_expense/models/hr_expense.py
+++ b/addons/hr_expense/models/hr_expense.py
@@ -5,10 +5,9 @@ from markupsafe import Markup
import werkzeug
from odoo import api, fields, Command, models, _
-from odoo.tools import float_round
from odoo.exceptions import UserError, ValidationError
-from odoo.tools.misc import clean_context, format_date
-from odoo.tools import email_split, float_repr, float_compare, is_html_empty
+from odoo.tools.misc import format_date
+from odoo.tools import email_split, float_repr, float_round, is_html_empty
class HrExpense(models.Model):
@@ -25,60 +24,58 @@ class HrExpense(models.Model):
raise ValidationError(_('The current user has no related employee. Please, create one.'))
return employee
- name = fields.Char('Description', compute='_compute_name', readonly=False, store=True, precompute=True, required=True, copy=True)
- date = fields.Date(default=fields.Date.context_today, string="Expense Date")
- accounting_date = fields.Date(string="Accounting Date", related='sheet_id.accounting_date', store=True, groups='account.group_account_invoice,account.group_account_readonly')
- employee_id = fields.Many2one('hr.employee', compute='_compute_employee_id', string="Employee", precompute=True,
- store=True, required=True, readonly=False, tracking=True,
- default=_default_employee_id, domain=[('filter_for_expense', '=', True)], check_company=True)
- # product_id not required to allow create an expense without product via mail alias, but should be required on the view.
+ name = fields.Char(
+ string="Description",
+ compute='_compute_name', precompute=True, store=True, readonly=False,
+ required=True,
+ copy=True,
+ )
+ date = fields.Date(string="Expense Date", default=fields.Date.context_today)
+ employee_id = fields.Many2one(
+ comodel_name='hr.employee',
+ string="Employee",
+ compute='_compute_employee_id', precompute=True, store=True, readonly=False,
+ required=True,
+ default=_default_employee_id,
+ check_company=True,
+ domain=[('filter_for_expense', '=', True)],
+ tracking=True,
+ )
+ company_id = fields.Many2one(
+ comodel_name='res.company',
+ string="Company",
+ required=True,
+ readonly=True,
+ default=lambda self: self.env.company,
+ )
+ # product_id is not required to allow to create an expense without product via mail alias, but should be required on the view.
product_id = fields.Many2one(
- 'product.product',
- string='Category',
+ comodel_name='product.product',
+ string="Category",
tracking=True,
check_company=True,
domain=[('can_be_expensed', '=', True)],
ondelete='restrict',
)
product_description = fields.Html(compute='_compute_product_description')
- product_uom_id = fields.Many2one('uom.uom', string='Unit of Measure', compute='_compute_product_uom_id',
- store=True, precompute=True, copy=True, readonly=True,
- domain="[('category_id', '=', product_uom_category_id)]")
- product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id', readonly=True, string="UoM Category")
- unit_amount = fields.Float("Unit Price", compute='_compute_unit_amount', readonly=False, store=True, precompute=True, required=True, copy=True,
- digits='Product Price')
- unit_amount_display = fields.Float("Unit Price Display", compute='_compute_unit_amount_display')
- quantity = fields.Float(required=True, digits='Product Unit of Measure', default=1)
- tax_ids = fields.Many2many('account.tax', 'expense_tax', 'expense_id', 'tax_id',
- compute='_compute_tax_ids', store=True, readonly=False, precompute=True,
- check_company=True,
- domain="[('type_tax_use', '=', 'purchase')]", string='Included taxes',
- help="Both price-included and price-excluded taxes will behave as price-included taxes for expenses.")
- amount_tax = fields.Monetary(string='Tax amount in Currency', help="Tax amount in currency", precompute=True, compute='_compute_amount_tax', store=True, currency_field='currency_id')
- amount_tax_company = fields.Monetary('Tax amount', help="Tax amount in company currency", precompute=True, compute='_compute_total_amount_company', store=True, currency_field='company_currency_id')
- total_amount = fields.Monetary("Total In Currency", precompute=True, compute='_compute_amount', store=True, currency_field='currency_id', tracking=True, readonly=False, inverse='_inverse_total_amount')
- untaxed_amount = fields.Monetary("Total Untaxed Amount In Currency", precompute=True, compute='_compute_amount_tax', store=True, currency_field='currency_id')
- company_currency_id = fields.Many2one('res.currency', string="Report Company Currency", related='company_id.currency_id', readonly=True)
- total_amount_company = fields.Monetary('Total', tracking=True, precompute=True,
- compute='_compute_total_amount_company', inverse='_inverse_total_amount_company', store=True, currency_field='company_currency_id', readonly=False)
- company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.company)
- currency_id = fields.Many2one('res.currency', precompute=True, string='Currency', required=True, store=True, readonly=False,
- compute='_compute_currency_id', default=lambda self: self.env.company.currency_id)
- currency_rate = fields.Float(compute='_compute_currency_rate', tracking=True)
- account_id = fields.Many2one(
- 'account.account',
- compute='_compute_account_id', store=True, readonly=False, precompute=True,
- string='Account',
- check_company=True,
- domain="[('account_type', 'not in', ('asset_receivable','liability_payable','asset_cash','liability_credit_card'))]",
- help="An expense account is expected",
+ product_uom_id = fields.Many2one(
+ comodel_name='uom.uom',
+ string="Unit of Measure",
+ compute='_compute_uom_id', precompute=True, store=True,
+ domain="[('category_id', '=', product_uom_category_id)]",
+ copy=True,
)
- description = fields.Text('Internal Notes')
- payment_mode = fields.Selection([
- ("own_account", "Employee (to reimburse)"),
- ("company_account", "Company")
- ], default='own_account', tracking=True, string="Paid By")
- attachment_number = fields.Integer('Number of Attachments', compute='_compute_attachment_number')
+ product_uom_category_id = fields.Many2one(
+ comodel_name='uom.category',
+ string="UoM Category",
+ related='product_id.uom_id.category_id',
+ readonly=True,
+ )
+ product_has_cost = fields.Boolean(compute='_compute_from_product') # Whether the product has a cost (standard_price) or not
+ product_has_tax = fields.Boolean(string="Whether tax is defined on a selected product", compute='_compute_from_product')
+ quantity = fields.Float(required=True, digits='Product Unit of Measure', default=1)
+ description = fields.Text(string="Internal Notes")
+ nb_attachment = fields.Integer(string="Number of Attachments", compute='_compute_nb_attachment')
state = fields.Selection(
selection=[
('draft', 'To Report'),
@@ -88,40 +85,147 @@ class HrExpense(models.Model):
('done', 'Done'),
('refused', 'Refused')
],
- string='Status', default='draft', index=True, copy=False,
- compute='_compute_state', readonly=True, store=True,
+ string="Status",
+ compute='_compute_state', store=True, readonly=True,
+ index=True,
+ copy=False,
+ default='draft',
)
- sheet_id = fields.Many2one('hr.expense.sheet', string="Expense Report", domain="[('employee_id', '=', employee_id), ('company_id', '=', company_id)]", readonly=True, copy=False)
- sheet_is_editable = fields.Boolean(compute='_compute_sheet_is_editable')
- approved_by = fields.Many2one('res.users', string='Approved By', related='sheet_id.user_id', tracking=False)
- approved_on = fields.Datetime(string='Approved On', related='sheet_id.approval_date')
- reference = fields.Char("Bill Reference")
+ sheet_id = fields.Many2one(
+ comodel_name='hr.expense.sheet',
+ string="Expense Report",
+ domain="[('employee_id', '=', employee_id), ('company_id', '=', company_id)]",
+ readonly=True,
+ copy=False,
+ )
+ approved_by = fields.Many2one(comodel_name='res.users', string="Approved By", related='sheet_id.user_id', tracking=False)
+ approved_on = fields.Datetime(string="Approved On", related='sheet_id.approval_date')
+ duplicate_expense_ids = fields.Many2many(comodel_name='hr.expense', compute='_compute_duplicate_expense_ids') # Used to trigger warnings
- is_editable = fields.Boolean("Is Editable By Current User", compute='_compute_is_editable')
- is_ref_editable = fields.Boolean("Reference Is Editable By Current User", compute='_compute_is_ref_editable')
- product_has_cost = fields.Boolean("Is product with non zero cost selected", compute='_compute_product_has_cost')
- product_has_tax = fields.Boolean("Whether tax is defined on a selected product", compute='_compute_product_has_cost')
- same_currency = fields.Boolean("Is currency_id different from the company_currency_id", compute='_compute_same_currency')
- duplicate_expense_ids = fields.Many2many('hr.expense', compute='_compute_duplicate_expense_ids')
+ # Amount fields
+ tax_amount_currency = fields.Monetary(
+ string="Tax amount in Currency",
+ currency_field='currency_id',
+ compute='_compute_tax_amount_currency', precompute=True, store=True,
+ help="Tax amount in currency",
+ )
+ tax_amount = fields.Monetary(
+ string="Tax amount",
+ currency_field='company_currency_id',
+ compute='_compute_tax_amount', precompute=True, store=True,
+ help="Tax amount in company currency",
+ )
+ total_amount_currency = fields.Monetary(
+ string="Total In Currency",
+ currency_field='currency_id',
+ compute='_compute_total_amount_currency', precompute=True, store=True, readonly=False,
+ tracking=True,
+ )
+ untaxed_amount_currency = fields.Monetary(
+ string="Total Untaxed Amount In Currency",
+ currency_field='currency_id',
+ compute='_compute_tax_amount_currency', precompute=True, store=True,
+ )
+ total_amount = fields.Monetary(
+ string="Total",
+ currency_field='company_currency_id',
+ compute='_compute_total_amount', inverse='_inverse_total_amount', precompute=True, store=True, readonly=False,
+ tracking=True,
+ )
+ price_unit = fields.Monetary(
+ string="Unit Price",
+ currency_field='company_currency_id',
+ compute='_compute_price_unit', precompute=True, store=True, required=True, readonly=True,
+ copy=True,
+ )
+ currency_id = fields.Many2one(
+ comodel_name='res.currency',
+ string="Currency",
+ compute='_compute_currency_id', precompute=True, store=True, readonly=False,
+ required=True,
+ default=lambda self: self.env.company.currency_id,
+ )
+ company_currency_id = fields.Many2one(
+ comodel_name='res.currency',
+ related='company_id.currency_id',
+ string="Report Company Currency",
+ readonly=True,
+ )
+ is_multiple_currency = fields.Boolean(
+ string="Is currency_id different from the company_currency_id",
+ compute='_compute_is_multiple_currency',
+ )
+ currency_rate = fields.Float(compute='_compute_currency_rate', digits=(12, 6), readonly=True, tracking=True)
+ label_currency_rate = fields.Char(compute='_compute_currency_rate', readonly=True)
- sample = fields.Boolean()
- label_convert_rate = fields.Char(compute='_compute_label_convert_rate')
+ # Account fields
+ payment_mode = fields.Selection(
+ selection=[
+ ('own_account', "Employee (to reimburse)"),
+ ('company_account', "Company")
+ ],
+ string="Paid By",
+ default='own_account',
+ tracking=True,
+ )
+ account_id = fields.Many2one(
+ comodel_name='account.account',
+ string="Account",
+ compute='_compute_account_id', precompute=True, store=True, readonly=False,
+ domain="[('account_type', 'not in', ('asset_receivable', 'liability_payable', 'asset_cash', 'liability_credit_card')), ('company_id', '=', company_id)]",
+ help="An expense account is expected",
+ )
+ tax_ids = fields.Many2many(
+ comodel_name='account.tax',
+ relation='expense_tax',
+ column1='expense_id',
+ column2='tax_id',
+ string="Included taxes",
+ compute='_compute_tax_ids', precompute=True, store=True, readonly=False,
+ domain="[('company_id', '=', company_id), ('type_tax_use', '=', 'purchase')]",
+ help="Both price-included and price-excluded taxes will behave as price-included taxes for expenses.",
+ )
+ accounting_date = fields.Date( # The date used for the accounting entries or the one we'd like to use if not yet posted
+ related='sheet_id.accounting_date',
+ string="Accounting Date",
+ store=True,
+ groups='account.group_account_invoice,account.group_account_readonly',
+ )
- def attach_document(self, **kwargs):
- pass
+ # Security fields
+ is_editable = fields.Boolean(string="Is Editable By Current User", compute='_compute_is_editable')
@api.depends('product_has_cost')
def _compute_currency_id(self):
for expense in self.filtered("product_has_cost"):
expense.currency_id = expense.company_currency_id
+ @api.depends('sheet_id.is_editable')
+ def _compute_is_editable(self):
+ for expense in self:
+ if expense.sheet_id:
+ expense.is_editable = expense.sheet_id.is_editable
+ else:
+ expense.is_editable = True
+
@api.onchange('product_has_cost')
def _onchange_product_has_cost(self):
- # Reset quantity to 1, in case of 0-cost product
+ """ Reset quantity to 1, in case of 0-cost product. To make sure switching non-0-cost to 0-cost doesn't keep the quantity."""
if not self.product_has_cost:
self.quantity = 1
- @api.depends('currency_id', 'total_amount', 'date')
+ @api.depends_context('lang')
+ @api.depends('product_id')
+ def _compute_product_description(self):
+ for expense in self:
+ expense.product_description = not is_html_empty(expense.product_id.description) and expense.product_id.description
+
+ @api.depends('product_id')
+ def _compute_name(self):
+ for expense in self:
+ expense.name = expense.name or expense.product_id.display_name
+
+ @api.depends('currency_id', 'total_amount_currency', 'date')
def _compute_currency_rate(self):
"""
We want the default odoo rate when the following change:
@@ -133,10 +237,10 @@ class HrExpense(models.Model):
"""
date_today = fields.Date.context_today(self)
for expense in self:
- if not expense.same_currency:
+ if expense.is_multiple_currency:
if (
expense.currency_id != expense._origin.currency_id
- or expense.total_amount != expense._origin.total_amount
+ or expense.total_amount_currency != expense._origin.total_amount_currency
or expense.date != expense._origin.date
):
expense.currency_rate = self.env['res.currency']._get_conversion_rate(
@@ -146,21 +250,34 @@ class HrExpense(models.Model):
date=expense.date or date_today,
)
else:
- expense.currency_rate = expense.total_amount_company / expense.total_amount if expense.total_amount else 1.0
- else:
+ expense.currency_rate = expense.total_amount / expense.total_amount_currency if expense.total_amount_currency else 1.0
+ else: # Mono-currency case computation shortcut, no need for the label if there is no conversion
expense.currency_rate = 1.0
+ expense.label_currency_rate = False
+ continue
+
+ expense.label_currency_rate = _(
+ '1 %(exp_cur)s = %(rate)s %(comp_cur)s',
+ exp_cur=expense.currency_id.name,
+ rate=float_repr(expense.currency_rate, 6),
+ comp_cur=expense.company_currency_id.name,
+ )
@api.depends('currency_id', 'company_currency_id')
- def _compute_same_currency(self):
+ def _compute_is_multiple_currency(self):
for expense in self:
- expense.same_currency = bool(not expense.company_id or (expense.currency_id and expense.currency_id == expense.company_currency_id))
+ expense.is_multiple_currency = expense.currency_id != expense.company_currency_id
@api.depends('product_id.standard_price')
- def _compute_product_has_cost(self):
+ def _compute_from_product(self):
for expense in self:
- expense.product_has_cost = expense.product_id and (float_compare(expense.product_id.standard_price, 0.0, precision_digits=2) != 0)
- tax_ids = expense.product_id.supplier_taxes_id.filtered_domain(self.env['account.tax']._check_company_domain(expense.company_id))
- expense.product_has_tax = bool(tax_ids)
+ expense.product_has_cost = expense.product_id and not expense.company_currency_id.is_zero(expense.product_id.standard_price)
+ expense.product_has_tax = bool(expense.product_id.supplier_taxes_id.filtered_domain(self.env['account.tax']._check_company_domain(expense.company_id)))
+
+ @api.depends('product_id.uom_id')
+ def _compute_uom_id(self):
+ for expense in self:
+ expense.product_uom_id = expense.product_id.uom_id
@api.depends('sheet_id', 'sheet_id.account_move_ids', 'sheet_id.state')
def _compute_state(self):
@@ -178,160 +295,115 @@ class HrExpense(models.Model):
else:
expense.state = 'done'
- @api.depends('quantity', 'unit_amount', 'tax_ids')
- def _compute_amount(self):
- for expense in self:
- if not expense.product_has_cost:
- continue
- base_lines = [expense._convert_to_tax_base_line_dict(price_unit=expense.unit_amount, quantity=expense.quantity)]
+ @api.depends('quantity', 'price_unit', 'tax_ids')
+ def _compute_total_amount_currency(self):
+ for expense in self.filtered('product_has_cost'):
+ base_lines = [expense._convert_to_tax_base_line_dict(price_unit=expense.price_unit, quantity=expense.quantity)]
taxes_totals = self.env['account.tax']._compute_taxes(base_lines)['totals'][expense.currency_id]
- expense.total_amount = taxes_totals['amount_untaxed'] + taxes_totals['amount_tax']
+ expense.total_amount_currency = taxes_totals['amount_untaxed'] + taxes_totals['amount_tax']
- @api.depends('total_amount')
- def _compute_amount_tax(self):
- """
- Note: as total_amount can be set directly by the user (for product without cost) or needs to be computed (for product with cost),
- `untaxed_amount` can't be computed in the same method as `total_amount`.
- """
+ @api.onchange('total_amount_currency')
+ def _inverse_total_amount_currency(self):
for expense in self:
- base_lines = [expense._convert_to_tax_base_line_dict(price_unit=expense.total_amount)]
- taxes_totals = self.env['account.tax']._compute_taxes(base_lines)['totals'][expense.currency_id]
- expense.amount_tax = taxes_totals['amount_tax']
- expense.untaxed_amount = taxes_totals['amount_untaxed']
-
- def _convert_to_tax_base_line_dict(self, base_line=None, currency=None, price_unit=None, quantity=None):
- self.ensure_one()
- return self.env['account.tax']._convert_to_tax_base_line_dict(
- base_line,
- currency=currency or self.currency_id,
- product=self.product_id,
- taxes=self.tax_ids,
- price_unit=price_unit or self.total_amount_company,
- quantity=quantity or 1,
- account=self.account_id,
- analytic_distribution=self.analytic_distribution,
- extra_context={'force_price_include': True},
- )
+ if not expense.is_editable:
+ raise UserError(_('You are not authorized to edit this expense.'))
+ expense.price_unit = (expense.total_amount / expense.quantity) if expense.quantity != 0 else 0.
@api.depends(
'date',
- 'currency_id',
'company_id',
+ 'currency_id',
'company_currency_id',
- 'total_amount',
- 'tax_ids',
+ 'is_multiple_currency',
+ 'total_amount_currency',
'product_id',
'employee_id.user_id.partner_id',
'quantity',
)
- def _compute_total_amount_company(self):
+ def _compute_total_amount(self):
for expense in self:
- base_lines = [expense._convert_to_tax_base_line_dict(
- price_unit=expense.total_amount * expense.currency_rate,
- currency=expense.company_currency_id,
- )]
- taxes_totals = self.env['account.tax']._compute_taxes(base_lines)['totals'][expense.company_currency_id]
- expense.total_amount_company = taxes_totals['amount_untaxed'] + taxes_totals['amount_tax']
- expense.amount_tax_company = taxes_totals['amount_tax']
+ if expense.is_multiple_currency:
+ base_lines = [expense._convert_to_tax_base_line_dict(
+ price_unit=expense.total_amount_currency * expense.currency_rate,
+ currency=expense.company_currency_id,
+ )]
+ taxes_totals = self.env['account.tax']._compute_taxes(base_lines)['totals'][expense.company_currency_id]
+ expense.total_amount = taxes_totals['amount_untaxed'] + taxes_totals['amount_tax']
+ else: # Mono-currency case computation shortcut
+ expense.total_amount = expense.total_amount_currency
- def _inverse_total_amount_company(self):
- # Allows to set a custom rate on the expense
+ def _inverse_total_amount(self):
+ """ Allows to set a custom rate on the expense, and avoid the override when it makes no sense """
for expense in self:
- if expense.same_currency:
- expense.total_amount = expense.total_amount_company
- base_lines = [expense._convert_to_tax_base_line_dict(
- price_unit=expense.total_amount_company,
- currency=expense.company_currency_id,
- )]
- taxes_totals = self.env['account.tax']._compute_taxes(base_lines)['totals'][expense.company_currency_id]
-
- expense.amount_tax_company = taxes_totals['amount_tax']
- expense.currency_rate = expense.total_amount_company / expense.total_amount if expense.total_amount else 1.0
- expense.unit_amount = expense.total_amount_company / expense.quantity if expense.quantity else expense.total_amount_company
-
- @api.depends('currency_rate')
- def _compute_label_convert_rate(self):
- records_with_diff_currency = self.filtered(lambda x: not x.same_currency and x.currency_id)
- (self - records_with_diff_currency).label_convert_rate = False
- for expense in records_with_diff_currency:
- rate_txt = _('1 %(exp_cur)s = %(rate)s %(comp_cur)s', exp_cur=expense.currency_id.name, rate=float_repr(expense.currency_rate, 6), comp_cur=expense.company_currency_id.name)
- expense.label_convert_rate = rate_txt
-
- def _compute_attachment_number(self):
- attachment_data = self.env['ir.attachment']._read_group([('res_model', '=', 'hr.expense'), ('res_id', 'in', self.ids)], ['res_id'], ['__count'])
- attachment = dict(attachment_data)
- for expense in self:
- expense.attachment_number = attachment.get(expense._origin.id, 0)
-
- @api.depends('employee_id')
- def _compute_is_editable(self):
- is_account_manager = self.env.user.has_group('account.group_account_user') or self.env.user.has_group('account.group_account_manager')
- for expense in self:
- if expense.state in {'draft', 'reported'} or expense.sheet_id.state in {'draft', 'submit'}:
- expense.is_editable = True
- elif expense.sheet_id.state == 'approve':
- expense.is_editable = is_account_manager
+ if expense.is_multiple_currency:
+ base_lines = [expense._convert_to_tax_base_line_dict(
+ price_unit=expense.total_amount,
+ currency=expense.company_currency_id,
+ )]
+ taxes_totals = self.env['account.tax']._compute_taxes(base_lines)['totals'][expense.company_currency_id]
+ expense.tax_amount = taxes_totals['amount_tax']
else:
- expense.is_editable = False
-
- @api.depends('sheet_id.is_editable', 'sheet_id')
- def _compute_sheet_is_editable(self):
- for expense in self:
- expense.sheet_is_editable = not expense.sheet_id or expense.sheet_id.is_editable
-
- @api.depends('employee_id')
- def _compute_is_ref_editable(self):
- is_account_manager = self.env.user.has_group('account.group_account_user') or self.env.user.has_group('account.group_account_manager')
- for expense in self:
- if expense.state in {'draft', 'reported'} or expense.sheet_id.state in {'draft', 'submit'}:
- expense.is_ref_editable = True
- else:
- expense.is_ref_editable = is_account_manager
-
- @api.depends_context('lang')
- @api.depends('product_id')
- def _compute_product_description(self):
- for expense in self:
- expense.product_description = not is_html_empty(expense.product_id.description) and expense.product_id.description
-
- @api.depends('unit_amount')
- def _compute_unit_amount_display(self):
- # DEPRECATED
- for expense in self:
- expense.unit_amount_display = expense.unit_amount if expense.product_has_cost else expense.total_amount_company
-
- @api.depends('product_id')
- def _compute_name(self):
- for expense in self:
- expense.name = expense.name or expense.product_id.display_name
-
- @api.depends('product_id')
- def _compute_product_uom_id(self):
- for expense in self:
- expense.product_uom_id = expense.product_id.uom_id
-
- @api.depends('product_id', 'attachment_number', 'currency_rate')
- def _compute_unit_amount(self):
- for expense in self:
- if expense.product_id and expense.product_has_cost and not expense.attachment_number or (expense.attachment_number and not expense.unit_amount):
- expense.unit_amount = expense.product_id._price_compute('standard_price', currency=expense.currency_id)[expense.product_id.id]
- else: # Even if we don't add a product, the unit_amount is still used for the move.line balance computation
- expense.unit_amount = expense.company_currency_id.round(expense.total_amount_company / (expense.quantity or 1))
+ expense.total_amount_currency = expense.total_amount
+ expense.tax_amount = expense.tax_amount_currency
+ expense.currency_rate = expense.total_amount / expense.total_amount_currency if expense.total_amount_currency else 1.0
+ expense.price_unit = expense.total_amount / expense.quantity if expense.quantity else expense.total_amount
@api.depends('product_id', 'company_id')
def _compute_tax_ids(self):
- for expense in self:
- expense = expense.with_company(expense.company_id)
+ for _expense in self:
+ expense = _expense.with_company(_expense.company_id)
+ # taxes only from the same company
expense.tax_ids = expense.product_id.supplier_taxes_id.filtered_domain(self.env['account.tax']._check_company_domain(expense.company_id))
+ @api.depends('total_amount_currency', 'tax_ids')
+ def _compute_tax_amount_currency(self):
+ """
+ Note: as total_amount_currency can be set directly by the user (for product without cost)
+ or needs to be computed (for product with cost), `untaxed_amount_currency` can't be computed in the same method as `total_amount_currency`.
+ """
+ for expense in self:
+ base_lines = [expense._convert_to_tax_base_line_dict(price_unit=expense.total_amount_currency)]
+ taxes_totals = self.env['account.tax']._compute_taxes(base_lines)['totals'][expense.currency_id]
+ expense.tax_amount_currency = taxes_totals['amount_tax']
+ expense.untaxed_amount_currency = taxes_totals['amount_untaxed']
+
+ @api.depends('total_amount', 'currency_rate', 'tax_ids', 'is_multiple_currency')
+ def _compute_tax_amount(self):
+ """
+ Note: as total_amount can be set directly by the user when the currency_rate is overriden,
+ the tax must be computed after the total_amount.
+ """
+ for expense in self:
+ if expense.is_multiple_currency:
+ base_lines = [expense._convert_to_tax_base_line_dict(
+ price_unit=expense.total_amount,
+ currency=expense.company_currency_id,
+ )]
+ taxes_totals = self.env['account.tax']._compute_taxes(base_lines)['totals'][expense.company_currency_id]
+ expense.tax_amount = taxes_totals['amount_tax']
+ else: # Mono-currency case computation shortcut
+ expense.tax_amount = expense.tax_amount_currency
+
+ @api.depends('total_amount', 'total_amount_currency', 'nb_attachment')
+ def _compute_price_unit(self):
+ """
+ The price_unit is the unit price of the product if no product is set and no attachment overrides it.
+ Otherwise it is always computed from the total_amount and the quantity else it would break the vendor bill
+ when edited after creation.
+ """
+ for expense in self:
+ if expense.product_id and expense.product_has_cost and not expense.nb_attachment:
+ expense.price_unit = expense.product_id._price_compute('standard_price', currency=expense.company_currency_id)[expense.product_id.id]
+ else:
+ expense.price_unit = expense.company_currency_id.round(expense.total_amount / expense.quantity) if expense.quantity else 0.
@api.depends('product_id', 'company_id')
def _compute_account_id(self):
- for expense in self:
+ for _expense in self:
+ expense = _expense.with_company(_expense.company_id)
if not expense.product_id:
- expense.account_id = self.env['ir.property'].with_company(expense.company_id)._get('property_account_expense_categ_id', 'product.category')
+ expense.account_id = self.env['ir.property']._get('property_account_expense_categ_id', 'product.category')
continue
- expense = expense.with_company(expense.company_id)
account = expense.product_id.product_tmpl_id._get_product_accounts()['expense']
if account:
expense.account_id = account
@@ -342,11 +414,11 @@ class HrExpense(models.Model):
for expense in self:
expense.employee_id = self.env.user.with_company(expense.company_id).employee_id
- @api.depends('employee_id', 'product_id', 'total_amount')
+ @api.depends('employee_id', 'product_id', 'total_amount_currency')
def _compute_duplicate_expense_ids(self):
- self.duplicate_expense_ids = [(5, 0, 0)]
+ self.duplicate_expense_ids = [Command.clear()]
- expenses = self.filtered(lambda e: e.employee_id and e.product_id and e.total_amount)
+ expenses = self.filtered(lambda expense: expense.employee_id and expense.product_id and expense.total_amount_currency)
if expenses.ids:
duplicates_query = """
SELECT ARRAY_AGG(DISTINCT he.id)
@@ -354,22 +426,19 @@ class HrExpense(models.Model):
JOIN hr_expense AS ex ON he.employee_id = ex.employee_id
AND he.product_id = ex.product_id
AND he.date = ex.date
- AND he.total_amount = ex.total_amount
+ AND he.total_amount_currency = ex.total_amount_currency
AND he.company_id = ex.company_id
AND he.currency_id = ex.currency_id
WHERE ex.id in %(expense_ids)s
- GROUP BY he.employee_id, he.product_id, he.date, he.total_amount, he.company_id, he.currency_id
+ GROUP BY he.employee_id, he.product_id, he.date, he.total_amount_currency, he.company_id, he.currency_id
HAVING COUNT(he.id) > 1
"""
- self.env.cr.execute(duplicates_query, {
- 'expense_ids': tuple(expenses.ids),
- })
- duplicates = [x[0] for x in self.env.cr.fetchall()]
+ self.env.cr.execute(duplicates_query, {'expense_ids': tuple(expenses.ids)})
- for ids in duplicates:
- exp = expenses.filtered(lambda e: e.id in ids)
- exp.duplicate_expense_ids = [(6, 0, ids)]
- expenses = expenses - exp
+ for duplicates_ids in (x[0] for x in self.env.cr.fetchall()):
+ expenses_duplicates = expenses.filtered(lambda expense: expense.id in duplicates_ids)
+ expenses_duplicates.duplicate_expense_ids = [Command.set(duplicates_ids)]
+ expenses = expenses - expenses_duplicates
@api.depends('product_id', 'account_id')
def _compute_analytic_distribution(self):
@@ -382,33 +451,47 @@ class HrExpense(models.Model):
})
expense.analytic_distribution = distribution or expense.analytic_distribution
- @api.onchange('total_amount')
- def _inverse_total_amount(self):
+ def _compute_nb_attachment(self):
+ attachment_data = self.env['ir.attachment']._read_group(
+ [('res_model', '=', 'hr.expense'), ('res_id', 'in', self.ids)],
+ ['res_id'],
+ ['__count'],
+ )
+ attachment = dict(attachment_data)
for expense in self:
- expense.unit_amount = expense.total_amount_company / expense.quantity
+ expense.nb_attachment = attachment.get(expense._origin.id, 0)
@api.constrains('payment_mode')
def _check_payment_mode(self):
self.sheet_id._check_payment_mode()
- @api.constrains('product_id', 'product_uom_id')
- def _check_product_uom_category(self):
- for expense in self:
- if expense.product_id and expense.product_uom_id.category_id != expense.product_id.uom_id.category_id:
- raise UserError(_(
- 'Selected Unit of Measure for expense %(expense)s does not belong to the same category as the Unit of Measure of product %(product)s.',
- expense=expense.name, product=expense.product_id.name,
- ))
+ def _convert_to_tax_base_line_dict(self, base_line=None, currency=None, price_unit=None, quantity=None):
+ self.ensure_one()
+ return self.env['account.tax']._convert_to_tax_base_line_dict(
+ base_line,
+ currency=currency or self.currency_id,
+ product=self.product_id,
+ taxes=self.tax_ids,
+ price_unit=price_unit or self.total_amount,
+ quantity=quantity if quantity is not None else 1,
+ account=self.account_id,
+ analytic_distribution=self.analytic_distribution,
+ extra_context={'force_price_include': True},
+ )
+
+ def attach_document(self, **kwargs):
+ # To override
+ pass
def create_expense_from_attachments(self, attachment_ids=None, view_type='list'):
- ''' Create the expenses from files.
- :return: An action redirecting to hr.expense tree view.
- '''
- if attachment_ids is None:
- attachment_ids = []
- attachments = self.env['ir.attachment'].browse(attachment_ids)
- if not attachments:
+ """
+ Create the expenses from files.
+
+ :return: An action redirecting to hr.expense tree view.
+ """
+ if not attachment_ids:
raise UserError(_("No attachment was provided"))
+ attachments = self.env['ir.attachment'].browse(attachment_ids)
expenses = self.env['hr.expense']
if any(attachment.res_id or attachment.res_model != 'hr.expense' for attachment in attachments):
@@ -424,16 +507,13 @@ class HrExpense(models.Model):
attachment_name = '.'.join(attachment.name.split('.')[:-1])
vals = {
'name': attachment_name,
- 'unit_amount': 0,
+ 'price_unit': 0,
'product_id': self.env.company.expense_product_id.id or product.id,
}
if product.property_account_expense_id:
vals['account_id'] = product.property_account_expense_id.id
expense = self.env['hr.expense'].create(vals)
- attachment.write({
- 'res_model': 'hr.expense',
- 'res_id': expense.id,
- })
+ attachment.write({'res_model': 'hr.expense', 'res_id': expense.id})
attachment.register_as_main_attachment()
expenses += expense
@@ -452,7 +532,7 @@ class HrExpense(models.Model):
@api.ondelete(at_uninstall=False)
def _unlink_except_posted_or_approved(self):
for expense in self:
- if expense.state in ['done', 'approved']:
+ if expense.state in {'done', 'approved'}:
raise UserError(_('You cannot delete a posted or approved expense.'))
def write(self, vals):
@@ -461,10 +541,8 @@ class HrExpense(models.Model):
if 'tax_ids' in vals or 'analytic_distribution' in vals or 'account_id' in vals:
if any(not expense.is_editable for expense in self):
raise UserError(_('You are not authorized to edit this expense report.'))
- if 'reference' in vals:
- if any(not expense.is_ref_editable for expense in self):
- raise UserError(_('You are not authorized to edit the reference of this expense report.'))
res = super().write(vals)
+
if 'employee_id' in vals:
# In case expense has sheet which has only one expense_line_ids,
# then changing the expense.employee_id triggers changing the sheet.employee_id too.
@@ -513,8 +591,12 @@ class HrExpense(models.Model):
}
def _get_default_expense_sheet_values(self):
- # If there is an expense with total_amount_company == 0, it means that expense has not been processed by OCR yet
- expenses_with_amount = self.filtered(lambda expense: not float_compare(expense.total_amount_company, 0.0, precision_rounding=expense.company_currency_id.rounding) == 0)
+ # If there is an expense with total_amount == 0, it means that expense has not been processed by OCR yet
+ expenses_with_amount = self.filtered(lambda expense: not (
+ expense.currency_id.is_zero(expense.total_amount_currency)
+ or expense.company_currency_id.is_zero(expense.total_amount)
+ or not float_round(expense.quantity, precision_rounding=expense.product_uom_id.rounding)
+ ))
if any(expense.state != 'draft' or expense.sheet_id for expense in expenses_with_amount):
raise UserError(_("You cannot report twice the same line!"))
@@ -532,7 +614,7 @@ class HrExpense(models.Model):
company_expenses = expenses_with_amount - own_expenses
create_two_reports = own_expenses and company_expenses
- sheets = [own_expenses, company_expenses] if create_two_reports else [expenses_with_amount]
+ sheets = (own_expenses, company_expenses) if create_two_reports else (expenses_with_amount,)
values = []
for todo in sheets:
if len(todo) == 1:
@@ -543,28 +625,34 @@ class HrExpense(models.Model):
max_date = format_date(self.env, max(dates))
expense_name = min_date if max_date == min_date else f'{min_date} - {max_date}'
- vals = {
+ values.append({
'company_id': self.company_id.id,
'employee_id': self[0].employee_id.id,
'name': expense_name,
'expense_line_ids': [Command.set(todo.ids)],
'state': 'draft',
- }
- values.append(vals)
+ })
return values
def get_expenses_to_submit(self):
# if there ere no records selected, then select all draft expenses for the user
if self:
- expenses = self.filtered(lambda e: e.state == 'draft' and not e.sheet_id)
+ expenses = self.filtered(lambda expense: expense.state == 'draft' and not expense.sheet_id and expense.is_editable)
else:
- expenses = self.env['hr.expense'].search([('state', '=', 'draft'), ('sheet_id', '=', False), ('employee_id', '=', self.env.user.employee_id.id)])
+ expenses = self.env['hr.expense'].search([
+ ('state', '=', 'draft'),
+ ('sheet_id', '=', False),
+ ('employee_id', '=', self.env.user.employee_id.id),
+ ('is_editable', '=', True),
+ ])
if not expenses:
raise UserError(_('You have no expense to report'))
return expenses.action_submit_expenses()
def action_submit_expenses(self):
+ if self.filtered(lambda expense: not expense.is_editable):
+ raise UserError(_('You are not authorized to edit this expense.'))
context_vals = self._get_default_expense_sheet_values()
action_values = {
'name': _('New Expense Reports'),
@@ -592,8 +680,10 @@ class HrExpense(models.Model):
def action_get_attachment_view(self):
self.ensure_one()
res = self.env['ir.actions.act_window']._for_xml_id('base.action_attachment')
- res['domain'] = [('res_model', '=', 'hr.expense'), ('res_id', 'in', self.ids)]
- res['context'] = {'default_res_model': 'hr.expense', 'default_res_id': self.id}
+ res.update({
+ 'domain': [('res_model', '=', 'hr.expense'), ('res_id', 'in', self.ids)],
+ 'context': {'default_res_model': 'hr.expense', 'default_res_id': self.id},
+ })
return res
def action_approve_duplicates(self):
@@ -601,26 +691,26 @@ class HrExpense(models.Model):
for expense in self.duplicate_expense_ids:
expense.message_post(
body=_('%(user)s confirms this expense is not a duplicate with similar expense.', user=self.env.user.name),
- author_id=root
+ author_id=root,
)
def _get_split_values(self):
self.ensure_one()
- half_price = self.total_amount / 2
- price_round_up = float_round(half_price, precision_digits=2, rounding_method='UP')
- price_round_down = float_round(half_price, precision_digits=2, rounding_method='DOWN')
+ half_price = self.total_amount_currency / 2
+ price_round_up = float_round(half_price, precision_digits=self.currency_id.decimal_places, rounding_method='UP')
+ price_round_down = float_round(half_price, precision_digits=self.currency_id.decimal_places, rounding_method='DOWN')
return [{
'name': self.name,
'product_id': self.product_id.id,
- 'total_amount': price,
+ 'total_amount_currency': price,
'tax_ids': self.tax_ids.ids,
'currency_id': self.currency_id.id,
'company_id': self.company_id.id,
'analytic_distribution': self.analytic_distribution,
'employee_id': self.employee_id.id,
'expense_id': self.id,
- } for price in [price_round_up, price_round_down]]
+ } for price in (price_round_up, price_round_down)]
def action_split_wizard(self):
self.ensure_one()
@@ -657,11 +747,10 @@ class HrExpense(models.Model):
if not payment_method_line:
raise UserError(_("You need to add a manual payment method on the journal (%s)", journal.name))
move_lines = []
- # Due to rounding and conversion mismatch between vendor bills and payments, we have to force the computation into company account
tax_data = self.env['account.tax']._compute_taxes([
- self._convert_to_tax_base_line_dict(price_unit=self.total_amount, currency=self.currency_id)
+ self._convert_to_tax_base_line_dict(price_unit=self.total_amount_currency, currency=self.currency_id)
])
- rate = abs(self.total_amount / self.total_amount_company) if self.total_amount_company else 1.0
+ rate = abs(self.total_amount_currency / self.total_amount) if self.total_amount else 1.0
base_line_data, to_update = tax_data['base_lines_to_update'][0] # Add base line
amount_currency = to_update['price_subtotal']
expense_name = self.name.split("\n")[0][:64]
@@ -694,13 +783,13 @@ class HrExpense(models.Model):
'tax_repartition_line_id': tax_line_data['tax_repartition_line_id'],
}
move_lines.append(tax_line)
- base_move_line['balance'] = self.total_amount_company - total_tax_line_balance
+ base_move_line['balance'] = self.total_amount - total_tax_line_balance
expense_name = self.name.split("\n")[0][:64]
move_lines.append({ # Add outstanding payment line
'name': f'{self.employee_id.name}: {expense_name}',
'account_id': self.sheet_id._get_expense_account_destination(),
- 'balance': -self.total_amount_company,
- 'amount_currency': self.currency_id.round(-self.total_amount),
+ 'balance': -self.total_amount,
+ 'amount_currency': self.currency_id.round(-self.total_amount_currency),
'currency_id': self.currency_id.id,
})
return {
@@ -708,7 +797,7 @@ class HrExpense(models.Model):
'ref': self.name,
'journal_id': journal.id,
'move_type': 'entry',
- 'amount': self.total_amount,
+ 'amount': self.total_amount_currency,
'payment_type': 'outbound',
'partner_type': 'supplier',
'payment_method_line_id': payment_method_line.id,
@@ -723,7 +812,7 @@ class HrExpense(models.Model):
self.ensure_one()
account = self.account_id
if not account:
- # We need to do this as the installation process may delete the original account and it doesn't recompute properly after.
+ # We need to do this as the installation process may delete the original account, and it doesn't recompute properly after.
# This forces the default values if none is found
if self.product_id:
account = self.product_id.product_tmpl_id._get_product_accounts()['expense']
@@ -734,7 +823,7 @@ class HrExpense(models.Model):
'name': f'{self.employee_id.name}: {expense_name}',
'account_id': account.id,
'quantity': self.quantity or 1,
- 'price_unit': self.unit_amount,
+ 'price_unit': self.price_unit,
'product_id': self.product_id.id,
'product_uom_id': self.product_uom_id.id,
'analytic_distribution': self.analytic_distribution,
@@ -768,21 +857,18 @@ class HrExpense(models.Model):
if not self.env.user.employee_ids:
return expense_state
target_currency = self.env.company.currency_id
+ # Counting the expenses to display in the dashboard:
+ # - To submit: contains the expenses paid either by the employee or by the company, and that are draft or reported
+ # - Under validation: contains expenses paid by the employee or paid by the company, and that have been submitted but still need to be approved/refused
+ # - To be reimbursed: contains ONLY expenses paid by the employee that are approved, the payment has not yet been made
expenses = self._read_group(
[
('employee_id', 'in', self.env.user.employee_ids.ids),
- # Counting the expenses to display in the dashboard:
- # - To submit: contains the expenses paid either by the employee or by the company, and that are draft or reported
- # - Under validation: contains expenses paid by the employee or paid by the company, and that have been submitted but still need to be approved/refused
- # - To be reimbursed: contains ONLY expenses paid by the employee that are approved, the payment has not yet been made
- '|',
- '&', ('payment_mode', 'in', ('own_account', 'company_account')),
- ('state', 'in', ('draft', 'reported', 'submitted')),
- '&', ('payment_mode', '=', 'own_account'),
- ('state', '=', 'approved')
- ], ['state', 'currency_id'], ['total_amount:sum'])
+ '|', '&', ('payment_mode', 'in', ('own_account', 'company_account')), ('state', 'in', ('draft', 'reported', 'submitted')),
+ '&', ('payment_mode', '=', 'own_account'), ('state', '=', 'approved')
+ ], ['state', 'currency_id'], ['total_amount_currency:sum'])
for state, currency, total_amount_sum in expenses:
- if state in {'draft', 'reported'}: # Fusion the two states into only one "To Submit" state
+ if state in {'draft', 'reported'}: # Fuse the two states into only one "To Submit" state
state = 'to_submit'
currency = currency or target_currency
amount = currency._convert(total_amount_sum, target_currency, self.env.company, fields.Date.today())
@@ -797,11 +883,10 @@ class HrExpense(models.Model):
def message_new(self, msg_dict, custom_values=None):
email_address = email_split(msg_dict.get('email_from', False))[0]
- employee = self.env['hr.employee'].search([
- '|',
- ('work_email', 'ilike', email_address),
- ('user_id.email', 'ilike', email_address)
- ], limit=1)
+ employee = self.env['hr.employee'].search(
+ ['|', ('work_email', 'ilike', email_address), ('user_id.email', 'ilike', email_address)],
+ limit=1,
+ )
if not employee:
return super().message_new(msg_dict, custom_values=custom_values)
@@ -826,10 +911,10 @@ class HrExpense(models.Model):
vals = {
'employee_id': employee.id,
'name': expense_description,
- 'total_amount': price,
+ 'total_amount_currency': price,
'product_id': product.id if product else None,
'product_uom_id': product.uom_id.id,
- 'tax_ids': [(4, tax.id, False) for tax in product.supplier_taxes_id.filtered(lambda r: r.company_id == company)],
+ 'tax_ids': [Command.set(product.supplier_taxes_id.filtered(lambda r: r.company_id == company).ids)],
'quantity': 1,
'company_id': company.id,
'currency_id': currency_id.id
@@ -846,9 +931,9 @@ class HrExpense(models.Model):
@api.model
def _parse_product(self, expense_description):
"""
- Parse the subject to find the product.
- Product code should be the first word of expense_description
- Return product.product and updated description
+ Parse the subject to find the product.
+ Product code should be the first word of expense_description
+ Return product.product and updated description
"""
product_code = expense_description.split(' ')[0]
product = self.env['product.product'].search([('can_be_expensed', '=', True), ('default_code', '=ilike', product_code)], limit=1)
@@ -863,30 +948,30 @@ class HrExpense(models.Model):
symbols, symbols_pattern, float_pattern = [], '', r'[+-]?(\d+[.,]?\d*)'
price = 0.0
for currency in currencies:
- symbols.append(re.escape(currency.symbol))
- symbols.append(re.escape(currency.name))
+ symbols += [re.escape(currency.symbol), re.escape(currency.name)]
symbols_pattern = '|'.join(symbols)
- price_pattern = r'((%s)?\s?%s\s?(%s)?)' % (symbols_pattern, float_pattern, symbols_pattern)
+ price_pattern = f'(({symbols_pattern})?\\s?{float_pattern}\\s?({symbols_pattern})?)'
matches = re.findall(price_pattern, expense_description)
- currency = currencies and currencies[0]
+ currency = currencies[:1]
if matches:
- match = max(matches, key=lambda match: len([group for group in match if group])) # get the longuest match. e.g. "2 chairs 120$" -> the price is 120$, not 2
+ match = max(matches, key=lambda match: len([group for group in match if group]))
+ # get the longest match. e.g. "2 chairs 120$" -> the price is 120$, not 2
full_str = match[0]
currency_str = match[1] or match[3]
price = match[2].replace(',', '.')
if currency_str and currencies:
currencies = currencies.filtered(lambda c: currency_str in [c.symbol, c.name])
- currency = (currencies and currencies[0]) or currency
- expense_description = expense_description.replace(full_str, ' ') # remove price from description
+ currency = currencies[:1] or currency
+ expense_description = expense_description.replace(full_str, ' ') # remove price from description
expense_description = re.sub(' +', ' ', expense_description.strip())
- price = float(price)
- return price, currency, expense_description
+ return float(price), currency, expense_description
@api.model
def _parse_expense_subject(self, expense_description, currencies):
- """ Fetch product, price and currency info from mail subject.
+ """
+ Fetch product, price and currency info from mail subject.
Product can be identified based on product name or product code.
It can be passed between [] or it can be placed at start.
@@ -904,12 +989,13 @@ class HrExpense(models.Model):
return product, price, currency_id, expense_description
- # TODO: Make api.multi
def _send_expense_success_mail(self, msg_dict, expense):
- mail_template_id = 'hr_expense.hr_expense_template_register' if expense.employee_id.user_id else 'hr_expense.hr_expense_template_register_no_user'
+ if expense.employee_id.user_id:
+ mail_template_id = 'hr_expense.hr_expense_template_register'
+ else:
+ mail_template_id = 'hr_expense.hr_expense_template_register_no_user'
rendered_body = self.env['ir.qweb']._render(mail_template_id, {'expense': expense})
body = self.env['mail.render.mixin']._replace_local_links(rendered_body)
- # TDE TODO: seems louche, check to use notify
if expense.employee_id.user_id.partner_id:
expense.message_post(
body=body,
@@ -928,641 +1014,3 @@ class HrExpense(models.Model):
'references': msg_dict.get('message_id'),
'subject': f'Re: {msg_dict.get("subject", "")}',
}).send()
-
-
-class HrExpenseSheet(models.Model):
- """
- Here are the rights associated with the expense flow
-
- Action Group Restriction
- =================================================================================
- Submit Employee Only his own
- Officer If he is expense manager of the employee, manager of the employee
- or the employee is in the department managed by the officer
- Manager Always
- Approve Officer Not his own and he is expense manager of the employee, manager of the employee
- or the employee is in the department managed by the officer
- Manager Always
- Post Anybody State = approve and journal_id defined
- Done Anybody State = approve and journal_id defined
- Cancel Officer Not his own and he is expense manager of the employee, manager of the employee
- or the employee is in the department managed by the officer
- Manager Always
- =================================================================================
- """
- _name = "hr.expense.sheet"
- _inherit = ['mail.thread.main.attachment', 'mail.activity.mixin']
- _description = "Expense Report"
- _order = "accounting_date desc, id desc"
- _check_company_auto = True
-
- @api.model
- def _default_employee_id(self):
- return self.env.user.employee_id
-
- @api.model
- def _default_journal_id(self):
- """ The journal is determining the company of the accounting entries generated from expense. We need to force journal company and expense sheet company to be the same. """
- company_journal_id = self.env.company.expense_journal_id
- if company_journal_id:
- return company_journal_id.id
- default_company_id = self.default_get(['company_id'])['company_id']
- journal = self.env['account.journal'].search([
- *self.env['account.journal']._check_company_domain(default_company_id),
- ('type', '=', 'purchase'),
- ], limit=1)
- return journal.id
-
- name = fields.Char('Expense Report Summary', required=True, tracking=True)
- expense_line_ids = fields.One2many('hr.expense', 'sheet_id', string='Expense Lines', copy=False)
- product_ids = fields.Many2many(
- 'product.product',
- string='Categories',
- compute='_compute_product_ids',
- search='_search_product_ids',
- check_company=True,
- )
- expense_number = fields.Integer(compute='_compute_expense_number', string='Number of Expenses')
- nb_account_move = fields.Integer(
- string='Number of Journal Entries',
- compute='_compute_nb_account_move',
- )
- state = fields.Selection([
- ('draft', 'To Submit'),
- ('submit', 'Submitted'),
- ('approve', 'Approved'),
- ('post', 'Posted'),
- ('done', 'Done'),
- ('cancel', 'Refused')
- ], compute='_compute_state', string='Status', index=True, readonly=True, store=True, tracking=True, copy=False, default='draft', required=True)
- approval_state = fields.Selection([
- ('submit', 'Submitted'),
- ('approve', 'Approved'),
- ('cancel', 'Refused'),
- ], copy=False)
- approval_date = fields.Datetime('Approval Date', readonly=True)
-
- company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.company)
- company_currency_id = fields.Many2one(comodel_name='res.currency', string="Report Company Currency", related='company_id.currency_id')
- employee_id = fields.Many2one('hr.employee', string="Employee", required=True, tracking=True, default=_default_employee_id, check_company=True, domain=[('filter_for_expense', '=', True)])
- address_id = fields.Many2one('res.partner', compute='_compute_from_employee_id', store=True, readonly=False, copy=True, string="Employee Home Address", check_company=True)
- department_id = fields.Many2one('hr.department', compute='_compute_from_employee_id', store=True, readonly=False, copy=False, string='Department')
- user_id = fields.Many2one('res.users', 'Manager', compute='_compute_from_employee_id', store=True, readonly=False, copy=False, tracking=True, domain=lambda self: [('groups_id', 'in', self.env.ref('hr_expense.group_hr_expense_team_approver').id)])
-
- # === Amount fields === #
- total_amount = fields.Monetary('Total', currency_field='company_currency_id', compute='_compute_amount', store=True, tracking=True)
- untaxed_amount = fields.Monetary('Untaxed Amount', currency_field='company_currency_id', compute='_compute_amount', store=True)
- total_amount_taxes = fields.Monetary('Taxes', currency_field='company_currency_id', compute='_compute_amount', store=True)
- amount_residual = fields.Monetary(string="Amount Due", currency_field='company_currency_id',
- compute='_compute_from_account_move_ids', store=True)
-
- currency_id = fields.Many2one('res.currency', string='Currency', compute='_compute_currency_id', store=True, readonly=False)
- is_multiple_currency = fields.Boolean("Handle lines with different currencies", compute='_compute_is_multiple_currency')
-
- # === Account fields === #
- payment_state = fields.Selection(
- selection=lambda self: self.env["account.move"]._fields["payment_state"].selection,
- string="Payment Status",
- copy=False,
- tracking=True,
- store=True, readonly=True, compute='_compute_from_account_move_ids')
- payment_mode = fields.Selection(
- string="Paid By",
- tracking=True,
- related='expense_line_ids.payment_mode', readonly=True)
- employee_journal_id = fields.Many2one(
- 'account.journal',
- string='Journal',
- check_company=True,
- domain="[('type', '=', 'purchase')]",
- default=_default_journal_id,
- help="The journal used when the expense is paid by employee.",
- )
- selectable_payment_method_line_ids = fields.Many2many('account.payment.method.line', compute='_compute_selectable_payment_method_line_ids')
- payment_method_line_id = fields.Many2one(
- comodel_name='account.payment.method.line',
- string="Payment Method",
- domain="[('id', 'in', selectable_payment_method_line_ids)]",
- compute='_compute_payment_method_line_id',
- store=True,
- readonly=False,
- help="The payment method used when the expense is paid by the company.",
- )
- accounting_date = fields.Date(
- string='Accounting Date',
- compute='_compute_accounting_date',
- store=True
- )
- account_move_ids = fields.One2many('account.move', 'expense_sheet_id', string='Journal Entries', readonly=True)
- journal_id = fields.Many2one(
- 'account.journal',
- compute='_compute_journal_id',
- string="Expense Journal",
- store=True,
- check_company=True,
- )
-
- # === Security fields === #
- can_reset = fields.Boolean('Can Reset', compute='_compute_can_reset')
- can_approve = fields.Boolean('Can Approve', compute='_compute_can_approve')
- cannot_approve_reason = fields.Char('Cannot Approve Reason', compute='_compute_can_approve')
- is_editable = fields.Boolean("Expense Lines Are Editable By Current User", compute='_compute_is_editable')
-
- _sql_constraints = [
- ('journal_id_required_posted', "CHECK((state IN ('post', 'done') AND journal_id IS NOT NULL) OR (state NOT IN ('post', 'done')))", 'The journal must be set on posted expense'),
- ]
-
- @api.depends('expense_line_ids.total_amount_company', 'expense_line_ids.amount_tax_company')
- def _compute_amount(self):
- for sheet in self:
- sheet.total_amount = sum(sheet.expense_line_ids.mapped('total_amount_company'))
- sheet.total_amount_taxes = sum(sheet.expense_line_ids.mapped('amount_tax_company'))
- sheet.untaxed_amount = sheet.total_amount - sheet.total_amount_taxes
-
- @api.depends('account_move_ids.payment_state', 'account_move_ids.amount_residual')
- def _compute_from_account_move_ids(self):
- for sheet in self:
- if sheet.payment_mode == 'company_account':
- if sheet.account_move_ids:
- # when the sheet is paid by the company, the state/amount of the related account_move_ids are not relevant
- # unless all moves have been reversed
- sheet.amount_residual = 0.
- if sheet.account_move_ids - sheet.account_move_ids.filtered('reversal_move_id'):
- sheet.payment_state = 'paid'
- else:
- sheet.payment_state = 'reversed'
- else:
- sheet.amount_residual = sum(sheet.account_move_ids.mapped('amount_residual'))
- payment_states = set(sheet.account_move_ids.mapped('payment_state'))
- if len(payment_states) <= 1: # If only 1 move or only one state
- sheet.payment_state = payment_states.pop() if payment_states else 'not_paid'
- elif 'partial' in payment_states or 'paid' in payment_states: # else if any are (partially) paid
- sheet.payment_state = 'partial'
- else:
- sheet.payment_state = 'not_paid'
- else:
- # Only one move is created when the expenses are paid by the employee
- if sheet.account_move_ids:
- sheet.amount_residual = sum(sheet.account_move_ids.mapped('amount_residual'))
- sheet.payment_state = sheet.account_move_ids[:1].payment_state
- else:
- sheet.amount_residual = 0.0
- sheet.payment_state = 'not_paid'
-
- @api.depends('selectable_payment_method_line_ids')
- def _compute_payment_method_line_id(self):
- for sheet in self:
- sheet.payment_method_line_id = sheet.selectable_payment_method_line_ids._origin[:1]
-
- @api.depends('employee_journal_id', 'payment_method_line_id')
- def _compute_journal_id(self):
- for sheet in self:
- sheet.journal_id = sheet.payment_method_line_id.journal_id if sheet.payment_mode == 'company_account'\
- else sheet.employee_journal_id
-
- @api.depends('company_id')
- def _compute_selectable_payment_method_line_ids(self):
- for sheet in self:
- sheet.selectable_payment_method_line_ids = sheet.company_id.company_expense_allowed_payment_method_line_ids\
- or self.env['account.payment.method.line'].search([('payment_type', '=', 'outbound'), ('company_id', '=', sheet.company_id.id)])
-
- @api.depends('account_move_ids', 'payment_state', 'approval_state')
- def _compute_state(self):
- for sheet in self:
- if sheet.payment_state != 'not_paid':
- sheet.state = 'done'
- elif sheet.account_move_ids:
- sheet.state = 'post'
- elif sheet.approval_state:
- sheet.state = sheet.approval_state
- else:
- sheet.state = 'draft'
-
- @api.depends('expense_line_ids.currency_id', 'company_currency_id')
- def _compute_currency_id(self):
- for sheet in self:
- if not sheet.expense_line_ids or sheet.is_multiple_currency or sheet.payment_mode == 'own_account':
- sheet.currency_id = sheet.company_currency_id
- else:
- sheet.currency_id = sheet.expense_line_ids[0].currency_id
-
- @api.depends('expense_line_ids.currency_id')
- def _compute_is_multiple_currency(self):
- for sheet in self:
- sheet.is_multiple_currency = len(sheet.expense_line_ids.mapped('currency_id')) > 1
-
- @api.depends('employee_id')
- def _compute_can_reset(self):
- is_expense_user = self.user_has_groups('hr_expense.group_hr_expense_team_approver')
- for sheet in self:
- sheet.can_reset = is_expense_user if is_expense_user else sheet.employee_id.user_id == self.env.user
-
- @api.depends_context('uid')
- @api.depends('employee_id')
- def _compute_can_approve(self):
- is_team_approver = self.user_has_groups('hr_expense.group_hr_expense_team_approver')
- is_approver = self.user_has_groups('hr_expense.group_hr_expense_user')
- is_hr_admin = self.user_has_groups('hr_expense.group_hr_expense_manager')
-
- for sheet in self:
- reason = False
- if not is_team_approver:
- reason = _("%s: Your are not a Manager or HR Officer", sheet.name)
-
- elif not is_hr_admin:
- current_managers = sheet.employee_id.expense_manager_id | sheet.employee_id.parent_id.user_id | sheet.employee_id.department_id.manager_id.user_id | sheet.user_id
-
- if self.employee_id.user_id == self.env.user:
- reason = _("%s: It is your own expense", sheet.name)
-
- elif not self.env.user in current_managers and not is_approver and sheet.employee_id.expense_manager_id != self.env.user:
- reason = _("%s: It is not from your department", sheet.name)
-
- sheet.can_approve = not reason
- sheet.cannot_approve_reason = reason
-
- @api.depends('expense_line_ids')
- def _compute_expense_number(self):
- read_group_result = self.env['hr.expense']._read_group([('sheet_id', 'in', self.ids)], ['sheet_id'], ['__count'])
- result = {sheet.id: count for sheet, count in read_group_result}
- for sheet in self:
- sheet.expense_number = result.get(sheet.id, 0)
-
- @api.depends('account_move_ids')
- def _compute_nb_account_move(self):
- for sheet in self:
- sheet.nb_account_move = len(sheet.account_move_ids)
-
- @api.depends('account_move_ids.date')
- def _compute_accounting_date(self):
- for sheet in self:
- sheet.accounting_date = sheet.account_move_ids[:1].date
-
- @api.depends('employee_id', 'employee_id.department_id')
- def _compute_from_employee_id(self):
- for sheet in self:
- sheet.address_id = sheet.employee_id.sudo().work_contact_id
- sheet.department_id = sheet.employee_id.department_id
- sheet.user_id = sheet.employee_id.expense_manager_id or sheet.employee_id.parent_id.user_id
-
- @api.depends_context('uid')
- @api.depends('employee_id', 'user_id', 'state')
- def _compute_is_editable(self):
- is_manager = self.user_has_groups('hr_expense.group_hr_expense_manager')
- is_approver = self.user_has_groups('hr_expense.group_hr_expense_user')
- for report in self:
- # Employee can edit his own expense in draft only
- is_editable = (report.employee_id.user_id == self.env.user and report.state == 'draft') or (is_manager and report.state in ['draft', 'submit', 'approve'])
- if not is_editable and report.state in ['draft', 'submit', 'approve']:
- # expense manager can edit, unless it's own expense
- current_managers = report.employee_id.expense_manager_id | report.employee_id.parent_id.user_id | report.employee_id.department_id.manager_id.user_id | report.user_id
- is_editable = (is_approver or self.env.user in current_managers) and report.employee_id.user_id != self.env.user
- report.is_editable = is_editable
-
- @api.constrains('expense_line_ids')
- def _check_payment_mode(self):
- for sheet in self:
- expense_lines = sheet.mapped('expense_line_ids')
- if expense_lines and any(expense.payment_mode != expense_lines[0].payment_mode for expense in expense_lines):
- raise ValidationError(_("All expenses in an expense report must have the same \"paid by\" criteria."))
-
- @api.depends('expense_line_ids')
- def _compute_product_ids(self):
- for sheet in self:
- sheet.product_ids = sheet.expense_line_ids.mapped('product_id')
-
- @api.constrains('expense_line_ids', 'employee_id')
- def _check_employee(self):
- for sheet in self:
- employee_ids = sheet.expense_line_ids.mapped('employee_id')
- if len(employee_ids) > 1 or (len(employee_ids) == 1 and employee_ids != sheet.employee_id):
- raise ValidationError(_('You cannot add expenses of another employee.'))
-
- @api.constrains('expense_line_ids', 'company_id')
- def _check_expense_lines_company(self):
- for sheet in self:
- if any(expense.company_id != sheet.company_id for expense in sheet.expense_line_ids):
- raise ValidationError(_('An expense report must contain only lines from the same company.'))
-
- def _search_product_ids(self, operator, value):
- if operator == 'in' and not isinstance(value, list):
- value = [value]
- return [('expense_line_ids.product_id', operator, value)]
-
- # ----------------------------------------
- # ORM Overrides
- # ----------------------------------------
-
- def _read_format(self, fnames, load='_classic_read'):
- # setting the context in the field on the view is not enough
- self = self.with_context(show_payment_journal_id=True)
- return super()._read_format(fnames, load)
-
- @api.model_create_multi
- def create(self, vals_list):
- context = clean_context(self.env.context)
- context.update({
- 'mail_create_nosubscribe': True,
- 'mail_auto_subscribe_no_notify': True
- })
- sheets = super(HrExpenseSheet, self.with_context(context)).create(vals_list)
- sheets.activity_update()
- return sheets
-
- @api.ondelete(at_uninstall=False)
- def _unlink_except_posted_or_paid(self):
- for expense in self:
- if expense.state in ['post', 'done']:
- raise UserError(_('You cannot delete a posted or paid expense.'))
-
- # --------------------------------------------
- # Mail Thread
- # --------------------------------------------
-
- def _get_mail_thread_data_attachments(self):
- """
- In order to see in the sheet attachment preview the corresponding
- expenses' attachments, the latter attachments are added to the fetched data for the sheet record.
- """
- self.ensure_one()
- res = super()._get_mail_thread_data_attachments()
- expense_ids = self.expense_line_ids
- expense_attachments = self.env['ir.attachment'].search([('res_id', 'in', expense_ids.ids), ('res_model', '=', 'hr.expense')], order='id desc')
- return res | expense_attachments
-
- def _track_subtype(self, init_values):
- self.ensure_one()
- if 'state' in init_values and self.state == 'draft':
- return self.env.ref('hr_expense.mt_expense_reset')
- if 'state' in init_values and self.state == 'approve':
- if init_values['state'] in ('post', 'done'):
- return self.env.ref('hr_expense.mt_expense_entry_delete')
- return self.env.ref('hr_expense.mt_expense_approved')
- if 'state' in init_values and self.state == 'cancel':
- return self.env.ref('hr_expense.mt_expense_refused')
- if 'state' in init_values and self.state == 'done':
- return self.env.ref('hr_expense.mt_expense_paid')
- return super()._track_subtype(init_values)
-
- def _message_auto_subscribe_followers(self, updated_values, subtype_ids):
- res = super()._message_auto_subscribe_followers(updated_values, subtype_ids)
- if updated_values.get('employee_id'):
- employee = self.env['hr.employee'].browse(updated_values['employee_id'])
- if employee.user_id:
- res.append((employee.user_id.partner_id.id, subtype_ids, False))
- return res
-
- def activity_update(self):
- reports_requiring_feedback = self.env['hr.expense.sheet']
- reports_activity_unlink = self.env['hr.expense.sheet']
- for expense_report in self:
- if expense_report.state == 'submit':
- expense_report.activity_schedule(
- 'hr_expense.mail_act_expense_approval',
- user_id=expense_report.sudo()._get_responsible_for_approval().id or self.env.user.id)
- elif expense_report.state == 'approve':
- reports_requiring_feedback |= expense_report
- elif expense_report.state in ('draft', 'cancel'):
- reports_activity_unlink |= expense_report
- if reports_requiring_feedback:
- reports_requiring_feedback.activity_feedback(['hr_expense.mail_act_expense_approval'])
- if reports_activity_unlink:
- reports_activity_unlink.activity_unlink(['hr_expense.mail_act_expense_approval'])
-
- # --------------------------------------------
- # Actions
- # --------------------------------------------
-
- def action_submit_sheet(self):
- self._do_submit()
-
- def action_approve_expense_sheets(self):
- self._check_can_approve()
- self._validate_analytic_distribution()
- duplicates = self.expense_line_ids.duplicate_expense_ids.filtered(lambda exp: exp.state in ['approved', 'done'])
- if duplicates:
- action = self.env["ir.actions.act_window"]._for_xml_id('hr_expense.hr_expense_approve_duplicate_action')
- action['context'] = {'default_sheet_ids': self.ids, 'default_expense_ids': duplicates.ids}
- return action
- self._do_approve()
-
- def action_refuse_expense_sheets(self):
- self._check_can_refuse()
- action = self.env["ir.actions.act_window"]._for_xml_id('hr_expense.hr_expense_refuse_wizard_action')
- return action
-
- def action_reset_approval_expense_sheets(self):
- self._check_can_reset_approval()
- self._do_reset_approval()
-
- def action_sheet_move_create(self):
- self._check_can_create_move()
- self._do_create_moves()
-
- def action_reset_expense_sheets(self):
- self._do_reverse_moves()
- self._do_reset_approval()
-
- def action_register_payment(self):
- ''' Open the account.payment.register wizard to pay the selected journal entries.
- There can be more than one bank_account_id in the expense sheet when registering payment for multiple expenses.
- The default_partner_bank_id is set only if there is one available, if more than one the field is left empty.
- :return: An action opening the account.payment.register wizard.
- '''
- return self.account_move_ids.with_context(
- default_partner_bank_id=self.employee_id.sudo().bank_account_id.id if len(self.employee_id.sudo().bank_account_id.ids) <= 1 else None,
- ).action_register_payment()
-
- def action_open_expense_view(self):
- self.ensure_one()
- if self.expense_number == 1:
- return {
- 'type': 'ir.actions.act_window',
- 'view_mode': 'form',
- 'res_model': 'hr.expense',
- 'res_id': self.expense_line_ids.id,
- }
- return {
- 'name': _('Expenses'),
- 'type': 'ir.actions.act_window',
- 'view_mode': 'list,form',
- 'views': [[False, "list"], [False, "form"]],
- 'res_model': 'hr.expense',
- 'domain': [('id', 'in', self.expense_line_ids.ids)],
- }
-
- def action_open_account_moves(self):
- self.ensure_one()
- if self.payment_mode == 'own_account':
- res_model = 'account.move'
- record_ids = self.account_move_ids
- else:
- res_model = 'account.payment'
- record_ids = self.account_move_ids.mapped('payment_id')
-
- action = {'type': 'ir.actions.act_window', 'res_model': res_model}
- if len(self.account_move_ids) == 1:
- action.update({
- 'name': record_ids.name,
- 'view_mode': 'form',
- 'res_id': record_ids.id,
- 'views': [(False, 'form')],
- })
- else:
- action.update({
- 'name': _("Journal entries"),
- 'view_mode': 'list',
- 'domain': [('id', 'in', record_ids.ids)],
- 'views': [(False, 'list'), (False, 'form')],
- })
- return action
-
- # --------------------------------------------
- # Business
- # --------------------------------------------
-
- def set_to_paid(self):
- # hook used in other modules to bypass payment registration
- self.write({'state': 'done'})
-
- def set_to_posted(self):
- # hook used in other modules to bypass move creation
- self.write({'state': 'post'})
-
- def _check_can_approve(self):
- if not all(self.mapped('can_approve')):
- reasons = _("You cannot approve:\n %s", "\n".join(self.mapped('cannot_approve_reason')))
- raise UserError(reasons)
-
- def _check_can_refuse(self):
- if not all(self.mapped('can_approve')):
- reasons = _("You cannot refuse:\n %s", "\n".join(self.mapped('cannot_approve_reason')))
- raise UserError(reasons)
-
- def _check_can_reset_approval(self):
- if not all(self.mapped('can_reset')):
- raise UserError(_("Only HR Officers or the concerned employee can reset to draft."))
-
- def _check_can_create_move(self):
- if any(sheet.state != 'approve' for sheet in self):
- raise UserError(_("You can only generate accounting entry for approved expense(s)."))
-
- if any(not sheet.journal_id for sheet in self):
- raise UserError(_("Specify expense journal to generate accounting entries."))
-
- if not self.employee_id.work_email:
- raise UserError(_("The work email of the employee is required to post the expense report. Please add it on the employee form."))
-
- def _do_submit(self):
- self.write({'approval_state': 'submit'})
- self.sudo().activity_update()
-
- def _do_approve(self):
- for sheet in self.filtered(lambda s: s.state in ['submit', 'draft']):
- sheet.write({
- 'approval_state': 'approve',
- 'user_id': sheet.user_id.id or self.env.user.id,
- 'approval_date': fields.Date.context_today(sheet),
- })
- self.activity_update()
-
- def _do_reset_approval(self):
- self.sudo().write({
- 'approval_state': False,
- })
- self.activity_update()
-
- def _do_refuse(self, reason):
- self.write({'state': 'cancel'})
- subtype_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment')
- for sheet in self:
- sheet.message_post_with_source(
- 'hr_expense.hr_expense_template_refuse_reason',
- subtype_id=subtype_id,
- render_values={'reason': reason, 'name': sheet.name},
- )
- self.activity_update()
-
- def _do_create_moves(self):
- self = self.with_context(clean_context(self.env.context)) # remove default_*
- skip_context = {
- 'skip_invoice_sync':True,
- 'skip_invoice_line_sync':True,
- 'skip_account_move_synchronization':True,
- 'check_move_validity':False,
- }
- own_account_sheets = self.filtered(lambda sheet: sheet.payment_mode == 'own_account')
- company_account_sheets = self - own_account_sheets
-
- moves = self.env['account.move'].create([sheet._prepare_bills_vals() for sheet in own_account_sheets])
- payments = self.env['account.payment'].with_context(**skip_context).create([expense._prepare_payments_vals() for expense in company_account_sheets.expense_line_ids])
- moves |= payments.move_id
- moves.action_post()
- self.activity_update()
-
- return moves
-
- def _do_reverse_moves(self):
- draft_moves = self.account_move_ids.filtered(lambda _move: _move.state == 'draft')
- draft_moves.unlink()
- moves = self.account_move_ids - draft_moves
- moves._reverse_moves(default_values_list=[{'invoice_date': fields.Date.context_today(move), 'ref': False} for move in moves], cancel=True)
-
- def _prepare_bills_vals(self):
- self.ensure_one()
- return {
- **self._prepare_move_vals(),
- 'invoice_date': self.accounting_date or fields.Date.context_today(self),
- 'journal_id': self.journal_id.id,
- 'ref': self.name,
- 'move_type': 'in_invoice',
- 'partner_id': self.employee_id.sudo().work_contact_id.id,
- 'currency_id': self.currency_id.id,
- 'line_ids': [Command.create(expense._prepare_move_lines_vals()) for expense in self.expense_line_ids],
- 'attachment_ids': [
- Command.create(attachment.copy_data({'res_model': 'account.move', 'res_id': False, 'raw': attachment.raw})[0])
- for attachment in self.expense_line_ids.message_main_attachment_id
- ],
- }
-
- def _prepare_move_vals(self):
- self.ensure_one()
- return {
- # force the name to the default value, to avoid an eventual 'default_name' in the context
- # to set it to '' which cause no number to be given to the account.move when posted.
- 'name': '/',
- 'date': self.accounting_date or max(self.expense_line_ids.mapped('date')) or fields.Date.context_today(self),
- 'expense_sheet_id': self.id,
- }
-
- def _validate_analytic_distribution(self):
- for line in self.expense_line_ids:
- line._validate_distribution(**{
- 'account': line.account_id.id,
- 'business_domain': 'expense',
- 'company_id': line.company_id.id,
- })
-
- def _get_responsible_for_approval(self):
- if self.user_id:
- return self.user_id
- if self.employee_id.parent_id.user_id:
- return self.employee_id.parent_id.user_id
- if self.employee_id.department_id.manager_id.user_id:
- return self.employee_id.department_id.manager_id.user_id
- return self.env['res.users']
-
- def _get_expense_account_destination(self):
- self.ensure_one()
- if self.payment_mode == 'company_account':
- journal = self.payment_method_line_id.journal_id
- account_dest = (
- journal.outbound_payment_method_line_ids[:1].payment_account_id
- or journal.company_id.account_journal_payment_credit_account_id
- )
- else:
- if not self.employee_id.sudo().work_contact_id:
- raise UserError(_("No work contact found for the employee %s, please configure one.") % (self.employee_id.name))
- partner = self.employee_id.sudo().work_contact_id.with_company(self.company_id)
- account_dest = partner.property_account_payable_id or partner.parent_id.property_account_payable_id
- return account_dest.id
diff --git a/addons/hr_expense/models/hr_expense_sheet.py b/addons/hr_expense/models/hr_expense_sheet.py
new file mode 100644
index 00000000000..fb6af66dc81
--- /dev/null
+++ b/addons/hr_expense/models/hr_expense_sheet.py
@@ -0,0 +1,745 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, Command, models, _
+from odoo.exceptions import UserError, ValidationError
+from odoo.tools.misc import clean_context
+
+
+class HrExpenseSheet(models.Model):
+ """
+ Here are the rights associated with the expense flow
+
+ Action Group Restriction
+ =================================================================================
+ Submit Employee Only his own
+ Officer If he is expense manager of the employee, manager of the employee
+ or the employee is in the department managed by the officer
+ Manager Always
+ Approve Officer Not his own and he is expense manager of the employee, manager of the employee
+ or the employee is in the department managed by the officer
+ Manager Always
+ Post Anybody State = approve and journal_id defined
+ Done Anybody State = approve and journal_id defined
+ Cancel Officer Not his own and he is expense manager of the employee, manager of the employee
+ or the employee is in the department managed by the officer
+ Manager Always
+ =================================================================================
+ """
+ _name = "hr.expense.sheet"
+ _inherit = ['mail.thread.main.attachment', 'mail.activity.mixin']
+ _description = "Expense Report"
+ _order = "accounting_date desc, id desc"
+ _check_company_auto = True
+
+ @api.model
+ def _default_employee_id(self):
+ return self.env.user.employee_id
+
+ @api.model
+ def _default_journal_id(self):
+ """
+ The journal is determining the company of the accounting entries generated from expense.
+ We need to force journal company and expense sheet company to be the same.
+ """
+ company_journal_id = self.env.company.expense_journal_id
+ if company_journal_id:
+ return company_journal_id.id
+ default_company_id = self.default_get(['company_id'])['company_id']
+ journal = self.env['account.journal'].search([
+ *self.env['account.journal']._check_company_domain(default_company_id),
+ ('type', '=', 'purchase'),
+ ], limit=1)
+ return journal.id
+
+ name = fields.Char(string="Expense Report Summary", required=True, tracking=True)
+ expense_line_ids = fields.One2many(
+ comodel_name='hr.expense', inverse_name='sheet_id',
+ string="Expense Lines",
+ copy=False,
+ )
+ nb_expense = fields.Integer(compute='_compute_nb_expense', string="Number of Expenses")
+ state = fields.Selection(
+ selection=[
+ ('draft', 'To Submit'),
+ ('submit', 'Submitted'),
+ ('approve', 'Approved'),
+ ('post', 'Posted'),
+ ('done', 'Done'),
+ ('cancel', 'Refused')
+ ],
+ string="Status",
+ compute='_compute_state', store=True, readonly=True,
+ index=True,
+ required=True,
+ default='draft',
+ tracking=True,
+ copy=False,
+ )
+ approval_state = fields.Selection(
+ selection=[
+ ('submit', 'Submitted'),
+ ('approve', 'Approved'),
+ ('cancel', 'Refused'),
+ ],
+ copy=False,
+ )
+ approval_date = fields.Datetime(string="Approval Date", readonly=True)
+ company_id = fields.Many2one(
+ comodel_name='res.company',
+ string="Company",
+ required=True,
+ readonly=True,
+ default=lambda self: self.env.company,
+ )
+ employee_id = fields.Many2one(
+ comodel_name='hr.employee',
+ string="Employee",
+ required=True,
+ readonly=True,
+ default=_default_employee_id,
+ domain=[('filter_for_expense', '=', True)],
+ check_company=True,
+ tracking=True,
+ )
+
+ department_id = fields.Many2one(
+ comodel_name='hr.department',
+ related='employee_id.department_id',
+ string="Department",
+ store=True,
+ copy=False,
+ )
+ user_id = fields.Many2one(
+ comodel_name='res.users',
+ string="Manager",
+ compute='_compute_from_employee_id', store=True, readonly=True,
+ domain=lambda self: [('groups_id', 'in', self.env.ref('hr_expense.group_hr_expense_team_approver').id)],
+ copy=False,
+ tracking=True,
+ )
+ product_ids = fields.Many2many(
+ comodel_name='product.product',
+ string="Categories",
+ compute='_compute_product_ids',
+ search='_search_product_ids',
+ check_company=True,
+ )
+
+ # === Amount fields === #
+ total_amount = fields.Monetary(
+ string="Total",
+ currency_field='company_currency_id',
+ compute='_compute_amount', store=True, readonly=True,
+ tracking=True,
+ )
+ untaxed_amount = fields.Monetary(
+ string="Untaxed Amount",
+ currency_field='company_currency_id',
+ compute='_compute_amount', store=True, readonly=True,
+ )
+ total_tax_amount = fields.Monetary(
+ string="Taxes",
+ currency_field='company_currency_id',
+ compute='_compute_amount', store=True, readonly=True,
+ )
+ amount_residual = fields.Monetary(
+ string="Amount Due",
+ currency_field='company_currency_id',
+ compute='_compute_from_account_move_ids', store=True, readonly=True,
+ )
+ currency_id = fields.Many2one(
+ comodel_name='res.currency',
+ string="Currency",
+ compute='_compute_currency_id', store=True, readonly=True,
+ )
+ company_currency_id = fields.Many2one(
+ comodel_name='res.currency',
+ related='company_id.currency_id',
+ string="Report Company Currency"
+ )
+ is_multiple_currency = fields.Boolean(
+ string="Handle lines with different currencies",
+ compute='_compute_is_multiple_currency',
+ )
+
+ # === Account fields === #
+ payment_state = fields.Selection(
+ selection=lambda self: self.env["account.move"]._fields["payment_state"].selection,
+ string="Payment Status",
+ compute='_compute_from_account_move_ids', store=True, readonly=True,
+ copy=False,
+ tracking=True,
+ )
+ payment_mode = fields.Selection(
+ related='expense_line_ids.payment_mode',
+ string="Paid By",
+ tracking=True,
+ readonly=True,
+ )
+ employee_journal_id = fields.Many2one(
+ comodel_name='account.journal',
+ string="Journal",
+ default=_default_journal_id,
+ check_company=True,
+ domain=[('type', '=', 'purchase')],
+ help="The journal used when the expense is paid by employee.",
+ )
+ selectable_payment_method_line_ids = fields.Many2many(
+ comodel_name='account.payment.method.line',
+ compute='_compute_selectable_payment_method_line_ids',
+ )
+ payment_method_line_id = fields.Many2one(
+ comodel_name='account.payment.method.line',
+ string="Payment Method",
+ compute='_compute_payment_method_line_id', store=True, readonly=False,
+ domain="[('id', 'in', selectable_payment_method_line_ids)]",
+ help="The payment method used when the expense is paid by the company.",
+ )
+ accounting_date = fields.Date(string="Accounting Date", compute='_compute_accounting_date', store=True)
+ account_move_ids = fields.One2many(
+ string="Journal Entries",
+ comodel_name='account.move', inverse_name='expense_sheet_id', readonly=True,
+ )
+ nb_account_move = fields.Integer(string="Number of Journal Entries", compute='_compute_nb_account_move')
+ journal_id = fields.Many2one(
+ comodel_name='account.journal',
+ string="Expense Journal",
+ compute='_compute_journal_id', store=True,
+ check_company=True,
+ )
+
+ # === Security fields === #
+ can_reset = fields.Boolean(string='Can Reset', compute='_compute_can_reset')
+ can_approve = fields.Boolean(string='Can Approve', compute='_compute_can_approve')
+ cannot_approve_reason = fields.Char(string='Cannot Approve Reason', compute='_compute_can_approve')
+ is_editable = fields.Boolean(string="Expense Lines Are Editable By Current User", compute='_compute_is_editable')
+
+ _sql_constraints = [(
+ 'journal_id_required_posted',
+ "CHECK((state IN ('post', 'done') AND journal_id IS NOT NULL) OR (state NOT IN ('post', 'done')))",
+ 'The journal must be set on posted expense'
+ )]
+
+ @api.depends('expense_line_ids.total_amount', 'expense_line_ids.tax_amount')
+ def _compute_amount(self):
+ for sheet in self:
+ sheet.total_amount = sum(sheet.expense_line_ids.mapped('total_amount'))
+ sheet.total_tax_amount = sum(sheet.expense_line_ids.mapped('tax_amount'))
+ sheet.untaxed_amount = sheet.total_amount - sheet.total_tax_amount
+
+ @api.depends('account_move_ids.payment_state', 'account_move_ids.amount_residual')
+ def _compute_from_account_move_ids(self):
+ for sheet in self:
+ if sheet.payment_mode == 'company_account':
+ if sheet.account_move_ids:
+ # when the sheet is paid by the company, the state/amount of the related account_move_ids are not relevant
+ # unless all moves have been reversed
+ sheet.amount_residual = 0.
+ if sheet.account_move_ids - sheet.account_move_ids.filtered('reversal_move_id'):
+ sheet.payment_state = 'paid'
+ else:
+ sheet.payment_state = 'reversed'
+ else:
+ sheet.amount_residual = sum(sheet.account_move_ids.mapped('amount_residual'))
+ payment_states = set(sheet.account_move_ids.mapped('payment_state'))
+ if len(payment_states) <= 1: # If only 1 move or only one state
+ sheet.payment_state = payment_states.pop() if payment_states else 'not_paid'
+ elif 'partial' in payment_states or 'paid' in payment_states: # else if any are (partially) paid
+ sheet.payment_state = 'partial'
+ else:
+ sheet.payment_state = 'not_paid'
+ else:
+ # Only one move is created when the expenses are paid by the employee
+ if sheet.account_move_ids:
+ sheet.amount_residual = sum(sheet.account_move_ids.mapped('amount_residual'))
+ sheet.payment_state = sheet.account_move_ids[:1].payment_state
+ else:
+ sheet.amount_residual = 0.0
+ sheet.payment_state = 'not_paid'
+
+ @api.depends('selectable_payment_method_line_ids')
+ def _compute_payment_method_line_id(self):
+ for sheet in self:
+ sheet.payment_method_line_id = sheet.selectable_payment_method_line_ids._origin[:1]
+
+ @api.depends('employee_journal_id', 'payment_method_line_id')
+ def _compute_journal_id(self):
+ for sheet in self:
+ if sheet.payment_mode == 'company_account':
+ sheet.journal_id = sheet.payment_method_line_id.journal_id
+ else:
+ sheet.journal_id = sheet.employee_journal_id
+
+ @api.depends('company_id')
+ def _compute_selectable_payment_method_line_ids(self):
+ for sheet in self:
+ allowed_method_line_ids = sheet.company_id.company_expense_allowed_payment_method_line_ids
+ if allowed_method_line_ids:
+ sheet.selectable_payment_method_line_ids = allowed_method_line_ids
+ else:
+ sheet.selectable_payment_method_line_ids = self.env['account.payment.method.line'].search([
+ ('payment_type', '=', 'outbound'),
+ ('company_id', '=', sheet.company_id.id)
+ ])
+
+ @api.depends('account_move_ids', 'payment_state', 'approval_state')
+ def _compute_state(self):
+ for sheet in self:
+ if sheet.payment_state != 'not_paid':
+ sheet.state = 'done'
+ elif sheet.account_move_ids:
+ sheet.state = 'post'
+ elif sheet.approval_state:
+ sheet.state = sheet.approval_state
+ else:
+ sheet.state = 'draft'
+
+ @api.depends('expense_line_ids.currency_id', 'company_currency_id')
+ def _compute_currency_id(self):
+ for sheet in self:
+ if not sheet.expense_line_ids or sheet.is_multiple_currency or sheet.payment_mode == 'own_account':
+ sheet.currency_id = sheet.company_currency_id
+ else:
+ sheet.currency_id = sheet.expense_line_ids[:1].currency_id
+
+ @api.depends('expense_line_ids.currency_id')
+ def _compute_is_multiple_currency(self):
+ for sheet in self:
+ sheet.is_multiple_currency = any(sheet.expense_line_ids.mapped('is_multiple_currency')) \
+ or len(sheet.expense_line_ids.mapped('currency_id')) > 1
+
+ @api.depends('employee_id')
+ def _compute_can_reset(self):
+ is_expense_user = self.user_has_groups('hr_expense.group_hr_expense_team_approver')
+ for sheet in self:
+ sheet.can_reset = is_expense_user if is_expense_user else sheet.employee_id.user_id == self.env.user
+
+ @api.depends_context('uid')
+ @api.depends('employee_id')
+ def _compute_can_approve(self):
+ is_team_approver = self.user_has_groups('hr_expense.group_hr_expense_team_approver')
+ is_approver = self.user_has_groups('hr_expense.group_hr_expense_user')
+ is_hr_admin = self.user_has_groups('hr_expense.group_hr_expense_manager')
+
+ for sheet in self:
+ reason = False
+ if not is_team_approver:
+ reason = _("%s: Your are not a Manager or HR Officer", sheet.name)
+
+ elif not is_hr_admin:
+ sheet_employee = sheet.employee_id
+ current_managers = sheet_employee.expense_manager_id \
+ | sheet_employee.parent_id.user_id \
+ | sheet_employee.department_id.manager_id.user_id \
+ | sheet.user_id
+
+ if sheet_employee.user_id == self.env.user:
+ reason = _("%s: It is your own expense", sheet.name)
+
+ elif self.env.user not in current_managers and not is_approver and sheet_employee.expense_manager_id.id != self.env.user.id:
+ reason = _("%s: It is not from your department", sheet.name)
+
+ sheet.can_approve = not reason
+ sheet.cannot_approve_reason = reason
+
+ @api.depends('expense_line_ids')
+ def _compute_nb_expense(self):
+ for sheet in self:
+ sheet.nb_expense = len(sheet.expense_line_ids)
+
+ @api.depends('account_move_ids')
+ def _compute_nb_account_move(self):
+ for sheet in self:
+ sheet.nb_account_move = len(sheet.account_move_ids)
+
+ @api.depends('account_move_ids.date')
+ def _compute_accounting_date(self):
+ for sheet in self.filtered('account_move_ids'):
+ sheet.accounting_date = sheet.account_move_ids[:1].date
+
+ @api.depends('employee_id', 'employee_id.department_id')
+ def _compute_from_employee_id(self):
+ for sheet in self:
+ sheet.department_id = sheet.employee_id.department_id
+ sheet.user_id = sheet.employee_id.expense_manager_id or sheet.employee_id.parent_id.user_id
+
+ @api.depends_context('uid')
+ @api.depends('employee_id', 'user_id', 'state')
+ def _compute_is_editable(self):
+ is_hr_admin = self.user_has_groups('hr_expense.group_hr_expense_manager')
+ is_approver = self.user_has_groups('hr_expense.group_hr_expense_user')
+ for sheet in self:
+ if sheet.state not in {'draft', 'submit', 'approve'}:
+ # Not editable
+ sheet.is_editable = False
+ continue
+
+ employee = sheet.employee_id
+
+ is_own_sheet = employee.user_id == self.env.user
+ if is_own_sheet and sheet.state == 'draft':
+ # Anyone can edit their own draft sheet
+ sheet.is_editable = True
+ continue
+
+ managers = employee.expense_manager_id | employee.parent_id.user_id | employee.department_id.manager_id.user_id
+ if is_approver:
+ managers |= self.env.user
+ if not is_own_sheet and self.env.user in managers:
+ # If Approver-level or designated manager, can edit other people sheet
+ sheet.is_editable = True
+ continue
+
+ if is_hr_admin and sheet.state in {'draft', 'submit', 'approve'}:
+ # Administrator-level users are not restricted
+ sheet.is_editable = True
+ continue
+ sheet.is_editable = False
+
+ @api.constrains('expense_line_ids')
+ def _check_payment_mode(self):
+ for sheet in self:
+ expense_lines = sheet.mapped('expense_line_ids')
+ if expense_lines and any(expense.payment_mode != expense_lines[:1].payment_mode for expense in expense_lines):
+ raise ValidationError(_("All expenses in an expense report must have the same \"paid by\" criteria."))
+
+ @api.depends('expense_line_ids')
+ def _compute_product_ids(self):
+ for sheet in self:
+ sheet.product_ids = sheet.expense_line_ids.mapped('product_id')
+
+ @api.constrains('expense_line_ids', 'employee_id')
+ def _check_employee(self):
+ for sheet in self:
+ if sheet.expense_line_ids.employee_id - sheet.employee_id:
+ raise ValidationError(_('You cannot add expenses of another employee.'))
+
+ @api.constrains('expense_line_ids', 'company_id')
+ def _check_expense_lines_company(self):
+ for sheet in self:
+ if sheet.expense_line_ids.company_id - sheet.company_id:
+ raise ValidationError(_('An expense report must contain only lines from the same company.'))
+
+ @api.model
+ def _search_product_ids(self, operator, value):
+ if operator == 'in' and not isinstance(value, list):
+ value = [value]
+ return [('expense_line_ids.product_id', operator, value)]
+
+ # ----------------------------------------
+ # ORM Overrides
+ # ----------------------------------------
+
+ def _read_format(self, fnames, load='_classic_read'):
+ # setting the context in the field on the view is not enough
+ self = self.with_context(show_payment_journal_id=True)
+ return super()._read_format(fnames, load)
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ context = clean_context(self.env.context)
+ context.update({
+ 'mail_create_nosubscribe': True,
+ 'mail_auto_subscribe_no_notify': True,
+ })
+ sheets = super(HrExpenseSheet, self.with_context(context)).create(vals_list)
+ sheets.activity_update()
+ return sheets
+
+ @api.ondelete(at_uninstall=False)
+ def _unlink_except_posted_or_paid(self):
+ for expense in self:
+ if expense.state in {'post', 'done'}:
+ raise UserError(_('You cannot delete a posted or paid expense.'))
+
+ # --------------------------------------------
+ # Mail Thread
+ # --------------------------------------------
+
+ def _get_mail_thread_data_attachments(self):
+ """
+ In order to see in the sheet attachment preview the corresponding
+ expenses' attachments, the latter attachments are added to the fetched data for the sheet record.
+ """
+ self.ensure_one()
+ res = super()._get_mail_thread_data_attachments()
+ expense_attachments = self.env['ir.attachment'].search(
+ [('res_id', 'in', self.expense_line_ids.ids), ('res_model', '=', 'hr.expense')],
+ order='id desc',
+ )
+ return res | expense_attachments
+
+ def _track_subtype(self, init_values):
+ self.ensure_one()
+ if 'state' in init_values and self.state == 'draft':
+ return self.env.ref('hr_expense.mt_expense_reset')
+ if 'state' in init_values and self.state == 'approve':
+ if init_values['state'] in {'post', 'done'}:
+ return self.env.ref('hr_expense.mt_expense_entry_delete')
+ return self.env.ref('hr_expense.mt_expense_approved')
+ if 'state' in init_values and self.state == 'cancel':
+ return self.env.ref('hr_expense.mt_expense_refused')
+ if 'state' in init_values and self.state == 'done':
+ return self.env.ref('hr_expense.mt_expense_paid')
+ return super()._track_subtype(init_values)
+
+ def _message_auto_subscribe_followers(self, updated_values, subtype_ids):
+ res = super()._message_auto_subscribe_followers(updated_values, subtype_ids)
+ if updated_values.get('employee_id'):
+ employee_user = self.env['hr.employee'].browse(updated_values['employee_id']).user_id
+ if employee_user:
+ res.append((employee_user.partner_id.id, subtype_ids, False))
+ return res
+
+ def activity_update(self):
+ reports_requiring_feedback = self.env['hr.expense.sheet']
+ reports_activity_unlink = self.env['hr.expense.sheet']
+ for expense_report in self:
+ if expense_report.state == 'submit':
+ expense_report.activity_schedule(
+ 'hr_expense.mail_act_expense_approval',
+ user_id=expense_report.sudo()._get_responsible_for_approval().id or self.env.user.id)
+ elif expense_report.state == 'approve':
+ reports_requiring_feedback |= expense_report
+ elif expense_report.state in {'draft', 'cancel'}:
+ reports_activity_unlink |= expense_report
+ if reports_requiring_feedback:
+ reports_requiring_feedback.activity_feedback(['hr_expense.mail_act_expense_approval'])
+ if reports_activity_unlink:
+ reports_activity_unlink.activity_unlink(['hr_expense.mail_act_expense_approval'])
+
+ # --------------------------------------------
+ # Actions
+ # --------------------------------------------
+
+ def action_submit_sheet(self):
+ self._do_submit()
+
+ def action_approve_expense_sheets(self):
+ self._check_can_approve()
+ self._validate_analytic_distribution()
+ duplicates = self.expense_line_ids.duplicate_expense_ids.filtered(lambda exp: exp.state in {'approved', 'done'})
+ if duplicates:
+ action = self.env["ir.actions.act_window"]._for_xml_id('hr_expense.hr_expense_approve_duplicate_action')
+ action['context'] = {'default_sheet_ids': self.ids, 'default_expense_ids': duplicates.ids}
+ return action
+ self._do_approve()
+
+ def action_refuse_expense_sheets(self):
+ self._check_can_refuse()
+ return self.env["ir.actions.act_window"]._for_xml_id('hr_expense.hr_expense_refuse_wizard_action')
+
+ def action_reset_approval_expense_sheets(self):
+ self._check_can_reset_approval()
+ self._do_reset_approval()
+
+ def action_sheet_move_create(self):
+ self._check_can_create_move()
+ self._do_create_moves()
+
+ def action_reset_expense_sheets(self):
+ self._do_reverse_moves()
+ self._do_reset_approval()
+
+ def action_register_payment(self):
+ ''' Open the account.payment.register wizard to pay the selected journal entries.
+ There can be more than one bank_account_id in the expense sheet when registering payment for multiple expenses.
+ The default_partner_bank_id is set only if there is one available, if more than one the field is left empty.
+ :return: An action opening the account.payment.register wizard.
+ '''
+ return self.account_move_ids.with_context(default_partner_bank_id=(
+ self.employee_id.sudo().bank_account_id.id if len(self.employee_id.sudo().bank_account_id.ids) <= 1 else None
+ )).action_register_payment()
+
+ def action_open_expense_view(self):
+ self.ensure_one()
+ if self.nb_expense == 1:
+ return {
+ 'type': 'ir.actions.act_window',
+ 'view_mode': 'form',
+ 'res_model': 'hr.expense',
+ 'res_id': self.expense_line_ids.id,
+ }
+ return {
+ 'name': _('Expenses'),
+ 'type': 'ir.actions.act_window',
+ 'view_mode': 'list,form',
+ 'views': [[False, "list"], [False, "form"]],
+ 'res_model': 'hr.expense',
+ 'domain': [('id', 'in', self.expense_line_ids.ids)],
+ }
+
+ def action_open_account_moves(self):
+ self.ensure_one()
+ if self.payment_mode == 'own_account':
+ res_model = 'account.move'
+ record_ids = self.account_move_ids
+ else:
+ res_model = 'account.payment'
+ record_ids = self.account_move_ids.mapped('payment_id')
+
+ action = {'type': 'ir.actions.act_window', 'res_model': res_model}
+ if len(self.account_move_ids) == 1:
+ action.update({
+ 'name': record_ids.name,
+ 'view_mode': 'form',
+ 'res_id': record_ids.id,
+ 'views': [(False, 'form')],
+ })
+ else:
+ action.update({
+ 'name': _("Journal entries"),
+ 'view_mode': 'list',
+ 'domain': [('id', 'in', record_ids.ids)],
+ 'views': [(False, 'list'), (False, 'form')],
+ })
+ return action
+
+ # --------------------------------------------
+ # Business
+ # --------------------------------------------
+
+ def set_to_paid(self):
+ # hook used in other modules to bypass payment registration
+ self.write({'state': 'done'})
+
+ def set_to_posted(self):
+ # hook used in other modules to bypass move creation
+ self.write({'state': 'post'})
+
+ def _check_can_approve(self):
+ if not all(self.mapped('can_approve')):
+ reasons = _("You cannot approve:\n %s", "\n".join(self.mapped('cannot_approve_reason')))
+ raise UserError(reasons)
+
+ def _check_can_refuse(self):
+ if not all(self.mapped('can_approve')):
+ reasons = _("You cannot refuse:\n %s", "\n".join(self.mapped('cannot_approve_reason')))
+ raise UserError(reasons)
+
+ def _check_can_reset_approval(self):
+ if not all(self.mapped('can_reset')):
+ raise UserError(_("Only HR Officers or the concerned employee can reset to draft."))
+
+ def _check_can_create_move(self):
+ if any(sheet.state != 'approve' for sheet in self):
+ raise UserError(_("You can only generate accounting entry for approved expense(s)."))
+
+ if any(not sheet.journal_id for sheet in self):
+ raise UserError(_("Specify expense journal to generate accounting entries."))
+
+ if not self.employee_id.work_email:
+ raise UserError(_("The work email of the employee is required to post the expense report. Please add it on the employee form."))
+
+ def _do_submit(self):
+ self.write({'approval_state': 'submit'})
+ self.sudo().activity_update()
+
+ def _do_approve(self):
+ for sheet in self.filtered(lambda s: s.state in {'submit', 'draft'}):
+ sheet.write({
+ 'approval_state': 'approve',
+ 'user_id': sheet.user_id.id or self.env.user.id,
+ 'approval_date': fields.Date.context_today(sheet),
+ })
+ self.activity_update()
+
+ def _do_reset_approval(self):
+ self.sudo().write({'approval_state': False})
+ self.activity_update()
+
+ def _do_refuse(self, reason):
+ self.write({'state': 'cancel'})
+ subtype_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment')
+ for sheet in self:
+ sheet.message_post_with_source(
+ 'hr_expense.hr_expense_template_refuse_reason',
+ subtype_id=subtype_id,
+ render_values={'reason': reason, 'name': sheet.name},
+ )
+ self.activity_update()
+
+ def _do_create_moves(self):
+ self = self.with_context(clean_context(self.env.context)) # remove default_*
+ skip_context = {
+ 'skip_invoice_sync': True,
+ 'skip_invoice_line_sync': True,
+ 'skip_account_move_synchronization': True,
+ 'check_move_validity': False,
+ }
+ own_account_sheets = self.filtered(lambda sheet: sheet.payment_mode == 'own_account')
+ company_account_sheets = self - own_account_sheets
+
+ moves = self.env['account.move'].create([sheet._prepare_bills_vals() for sheet in own_account_sheets])
+ payments = self.env['account.payment'].with_context(**skip_context).create([
+ expense._prepare_payments_vals() for expense in company_account_sheets.expense_line_ids
+ ])
+ moves |= payments.move_id
+ moves.action_post()
+ self.activity_update()
+
+ return moves
+
+ def _do_reverse_moves(self):
+ draft_moves = self.account_move_ids.filtered(lambda account_move: account_move.state == 'draft')
+ draft_moves.unlink()
+ moves = self.account_move_ids - draft_moves
+ moves._reverse_moves(
+ default_values_list=[{'invoice_date': fields.Date.context_today(move), 'ref': False} for move in moves],
+ cancel=True,
+ )
+
+ def _prepare_bills_vals(self):
+ self.ensure_one()
+ return {
+ **self._prepare_move_vals(),
+ 'invoice_date': self.accounting_date or fields.Date.context_today(self),
+ 'journal_id': self.journal_id.id,
+ 'ref': self.name,
+ 'move_type': 'in_invoice',
+ 'partner_id': self.employee_id.sudo().work_contact_id.id,
+ 'currency_id': self.currency_id.id,
+ 'line_ids': [Command.create(expense._prepare_move_lines_vals()) for expense in self.expense_line_ids],
+ 'attachment_ids': [
+ Command.create(attachment.copy_data({'res_model': 'account.move', 'res_id': False, 'raw': attachment.raw})[0])
+ for attachment in self.expense_line_ids.message_main_attachment_id
+ ],
+ }
+
+ def _prepare_move_vals(self):
+ self.ensure_one()
+ return {
+ # force the name to the default value, to avoid an eventual 'default_name' in the context
+ # to set it to '' which cause no number to be given to the account.move when posted.
+ 'name': '/',
+ 'date': self.accounting_date or max(self.expense_line_ids.mapped('date')) or fields.Date.context_today(self),
+ 'expense_sheet_id': self.id,
+ }
+
+ def _validate_analytic_distribution(self):
+ for line in self.expense_line_ids:
+ line._validate_distribution(account=line.account_id.id, business_domain='expense', company_id=line.company_id.id)
+
+ def _get_responsible_for_approval(self):
+ if self.user_id:
+ return self.user_id
+ if self.employee_id.parent_id.user_id:
+ return self.employee_id.parent_id.user_id
+ if self.employee_id.department_id.manager_id.user_id:
+ return self.employee_id.department_id.manager_id.user_id
+ return self.env['res.users']
+
+ def _get_expense_account_destination(self):
+ self.ensure_one()
+ if self.payment_mode == 'company_account':
+ journal = self.payment_method_line_id.journal_id
+ account_dest = (
+ journal.outbound_payment_method_line_ids[:1].payment_account_id
+ or journal.company_id.account_journal_payment_credit_account_id
+ )
+ else:
+ if not self.employee_id.sudo().work_contact_id:
+ raise UserError(_("No work contact found for the employee %s, please configure one.", self.employee_id.name))
+ partner = self.employee_id.sudo().work_contact_id.with_company(self.company_id)
+ account_dest = partner.property_account_payable_id or partner.parent_id.property_account_payable_id
+ return account_dest.id
diff --git a/addons/hr_expense/report/hr_expense_report.xml b/addons/hr_expense/report/hr_expense_report.xml
index 602beb409a9..364717fa3f9 100644
--- a/addons/hr_expense/report/hr_expense_report.xml
+++ b/addons/hr_expense/report/hr_expense_report.xml
@@ -54,7 +54,7 @@
-