summaryrefslogtreecommitdiff
path: root/addons/hr_expense/models
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/hr_expense/models
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/hr_expense/models')
-rw-r--r--addons/hr_expense/models/__init__.py9
-rw-r--r--addons/hr_expense/models/account_journal_dashboard.py44
-rw-r--r--addons/hr_expense/models/account_move_line.py26
-rw-r--r--addons/hr_expense/models/hr_department.py16
-rw-r--r--addons/hr_expense/models/hr_employee.py57
-rw-r--r--addons/hr_expense/models/hr_expense.py1031
-rw-r--r--addons/hr_expense/models/product_template.py24
-rw-r--r--addons/hr_expense/models/res_config_settings.py32
8 files changed, 1239 insertions, 0 deletions
diff --git a/addons/hr_expense/models/__init__.py b/addons/hr_expense/models/__init__.py
new file mode 100644
index 00000000..af1e04d2
--- /dev/null
+++ b/addons/hr_expense/models/__init__.py
@@ -0,0 +1,9 @@
+# -*- coding: utf-8 -*-
+
+from . import hr_employee
+from . import account_move_line
+from . import hr_department
+from . import hr_expense
+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
new file mode 100644
index 00000000..23dbb802
--- /dev/null
+++ b/addons/hr_expense/models/account_journal_dashboard.py
@@ -0,0 +1,44 @@
+# -*- 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
+
+
+class AccountJournal(models.Model):
+ _inherit = "account.journal"
+
+ def _get_expenses_to_pay_query(self):
+ """
+ Returns a tuple containing as it's first element the SQL query used to
+ gather the expenses in reported state data, and the arguments
+ dictionary to use to run it as it's second.
+ """
+ query = """SELECT total_amount as amount_total, currency_id AS currency
+ FROM hr_expense_sheet
+ WHERE state IN ('approve', 'post')
+ and journal_id = %(journal_id)s"""
+ return (query, {'journal_id': self.id})
+
+ def get_journal_dashboard_datas(self):
+ res = super(AccountJournal, self).get_journal_dashboard_datas()
+ #add the number and sum of expenses to pay to the json defining the accounting dashboard data
+ (query, query_args) = self._get_expenses_to_pay_query()
+ self.env.cr.execute(query, query_args)
+ query_results_to_pay = self.env.cr.dictfetchall()
+ (number_to_pay, sum_to_pay) = self._count_results_and_sum_amounts(query_results_to_pay, self.company_id.currency_id)
+ res['number_expenses_to_pay'] = number_to_pay
+ res['sum_expenses_to_pay'] = formatLang(self.env, sum_to_pay or 0.0, currency_obj=self.currency_id or self.company_id.currency_id)
+ return res
+
+ def open_expenses_action(self):
+ action = self.env['ir.actions.act_window']._for_xml_id('hr_expense.action_hr_expense_sheet_all_all')
+ action['context'] = {
+ 'search_default_approved': 1,
+ 'search_default_to_post': 1,
+ 'search_default_journal_id': self.id,
+ 'default_journal_id': self.id,
+ }
+ action['view_mode'] = 'tree,form'
+ action['views'] = [(k,v) for k,v in action['views'] if v in ['tree', 'form']]
+ return action
diff --git a/addons/hr_expense/models/account_move_line.py b/addons/hr_expense/models/account_move_line.py
new file mode 100644
index 00000000..ae80e369
--- /dev/null
+++ b/addons/hr_expense/models/account_move_line.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+
+
+class AccountMoveLine(models.Model):
+ _inherit = "account.move.line"
+
+ expense_id = fields.Many2one('hr.expense', string='Expense', copy=False, help="Expense where the move line come from")
+
+ def reconcile(self):
+ # OVERRIDE
+ not_paid_expenses = self.expense_id.filtered(lambda expense: expense.state != 'done')
+ not_paid_expense_sheets = not_paid_expenses.sheet_id
+ res = super().reconcile()
+ paid_expenses = not_paid_expenses.filtered(lambda expense: expense.currency_id.is_zero(expense.amount_residual))
+ paid_expenses.write({'state': 'done'})
+ not_paid_expense_sheets.filtered(lambda sheet: all(expense.state == 'done' for expense in sheet.expense_line_ids)).set_to_paid()
+ return res
+
+ def _get_attachment_domains(self):
+ attachment_domains = super(AccountMoveLine, self)._get_attachment_domains()
+ if self.expense_id:
+ attachment_domains.append([('res_model', '=', 'hr.expense'), ('res_id', '=', self.expense_id.id)])
+ return attachment_domains
diff --git a/addons/hr_expense/models/hr_department.py b/addons/hr_expense/models/hr_department.py
new file mode 100644
index 00000000..9bf09b2b
--- /dev/null
+++ b/addons/hr_expense/models/hr_department.py
@@ -0,0 +1,16 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+
+
+class HrDepartment(models.Model):
+ _inherit = 'hr.department'
+
+ def _compute_expense_sheets_to_approve(self):
+ expense_sheet_data = self.env['hr.expense.sheet'].read_group([('department_id', 'in', self.ids), ('state', '=', 'submit')], ['department_id'], ['department_id'])
+ result = dict((data['department_id'][0], data['department_id_count']) for data in expense_sheet_data)
+ for department in self:
+ department.expense_sheets_to_approve_count = result.get(department.id, 0)
+
+ expense_sheets_to_approve_count = fields.Integer(compute='_compute_expense_sheets_to_approve', string='Expenses Reports to Approve')
diff --git a/addons/hr_expense/models/hr_employee.py b/addons/hr_expense/models/hr_employee.py
new file mode 100644
index 00000000..4888c3b3
--- /dev/null
+++ b/addons/hr_expense/models/hr_employee.py
@@ -0,0 +1,57 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models, api
+
+
+class Employee(models.Model):
+ _inherit = 'hr.employee'
+
+ def _group_hr_expense_user_domain(self):
+ # We return the domain only if the group exists for the following reason:
+ # When a group is created (at module installation), the `res.users` form view is
+ # automatically modifiedto add application accesses. When modifiying the view, it
+ # reads the related field `expense_manager_id` of `res.users` and retrieve its domain.
+ # This is a problem because the `group_hr_expense_user` record has already been created but
+ # not its associated `ir.model.data` which makes `self.env.ref(...)` fail.
+ group = self.env.ref('hr_expense.group_hr_expense_team_approver', raise_if_not_found=False)
+ return [('groups_id', 'in', group.ids)] if group else []
+
+ expense_manager_id = fields.Many2one(
+ 'res.users', string='Expense',
+ domain=_group_hr_expense_user_domain,
+ compute='_compute_expense_manager', store=True, readonly=False,
+ help='Select the user responsible for approving "Expenses" of this employee.\n'
+ 'If empty, the approval is done by an Administrator or Approver (determined in settings/users).')
+
+ @api.depends('parent_id')
+ def _compute_expense_manager(self):
+ for employee in self:
+ previous_manager = employee._origin.parent_id.user_id
+ manager = employee.parent_id.user_id
+ if manager and manager.has_group('hr_expense.group_hr_expense_user') and (employee.expense_manager_id == previous_manager or not employee.expense_manager_id):
+ employee.expense_manager_id = manager
+ elif not employee.expense_manager_id:
+ employee.expense_manager_id = False
+
+
+class EmployeePublic(models.Model):
+ _inherit = 'hr.employee.public'
+
+ expense_manager_id = fields.Many2one('res.users', readonly=True)
+
+
+class User(models.Model):
+ _inherit = ['res.users']
+
+ expense_manager_id = fields.Many2one(related='employee_id.expense_manager_id', readonly=False)
+
+ def __init__(self, pool, cr):
+ """ Override of __init__ to add access rights.
+ Access rights are disabled by default, but allowed
+ on some specific fields defined in self.SELF_{READ/WRITE}ABLE_FIELDS.
+ """
+ init_res = super(User, self).__init__(pool, cr)
+ # duplicate list to avoid modifying the original reference
+ type(self).SELF_READABLE_FIELDS = type(self).SELF_READABLE_FIELDS + ['expense_manager_id']
+ return init_res
diff --git a/addons/hr_expense/models/hr_expense.py b/addons/hr_expense/models/hr_expense.py
new file mode 100644
index 00000000..3fce60d2
--- /dev/null
+++ b/addons/hr_expense/models/hr_expense.py
@@ -0,0 +1,1031 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import re
+
+from odoo import api, fields, models, _
+from odoo.exceptions import UserError, ValidationError
+from odoo.tools import email_split, float_is_zero
+
+
+class HrExpense(models.Model):
+
+ _name = "hr.expense"
+ _inherit = ['mail.thread', 'mail.activity.mixin']
+ _description = "Expense"
+ _order = "date desc, id desc"
+ _check_company_auto = True
+
+ @api.model
+ def _default_employee_id(self):
+ employee = self.env.user.employee_id
+ if not employee and not self.env.user.has_group('hr_expense.group_hr_expense_team_approver'):
+ raise ValidationError(_('The current user has no related employee. Please, create one.'))
+ return employee
+
+ @api.model
+ def _default_product_uom_id(self):
+ return self.env['uom.uom'].search([], limit=1, order='id')
+
+ @api.model
+ def _default_account_id(self):
+ return self.env['ir.property']._get('property_account_expense_categ_id', 'product.category')
+
+ @api.model
+ def _get_employee_id_domain(self):
+ 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', '=', company_id)]" # Then, domain accepts everything
+ elif self.user_has_groups('hr_expense.group_hr_expense_team_approver') and self.env.user.employee_ids:
+ user = self.env.user
+ employee = self.env.user.employee_id
+ res = [
+ '|', '|', '|',
+ ('department_id.manager_id', '=', employee.id),
+ ('parent_id', '=', employee.id),
+ ('id', '=', employee.id),
+ ('expense_manager_id', '=', user.id),
+ '|', ('company_id', '=', False), ('company_id', '=', employee.company_id.id),
+ ]
+ elif self.env.user.employee_id:
+ employee = self.env.user.employee_id
+ res = [('id', '=', employee.id), '|', ('company_id', '=', False), ('company_id', '=', employee.company_id.id)]
+ return res
+
+ name = fields.Char('Description', compute='_compute_from_product_id_company_id', store=True, required=True, copy=True,
+ states={'draft': [('readonly', False)], 'reported': [('readonly', False)], 'refused': [('readonly', False)]})
+ date = fields.Date(readonly=True, states={'draft': [('readonly', False)], 'reported': [('readonly', False)], 'refused': [('readonly', False)]}, 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",
+ store=True, required=True, readonly=False, tracking=True,
+ states={'approved': [('readonly', True)], 'done': [('readonly', True)]},
+ default=_default_employee_id, domain=lambda self: self._get_employee_id_domain(), check_company=True)
+ # product_id not required to allow create an expense without product via mail alias, but should be required on the view.
+ product_id = fields.Many2one('product.product', string='Product', readonly=True, tracking=True, states={'draft': [('readonly', False)], 'reported': [('readonly', False)], 'refused': [('readonly', False)]}, domain="[('can_be_expensed', '=', True), '|', ('company_id', '=', False), ('company_id', '=', company_id)]", ondelete='restrict')
+ product_uom_id = fields.Many2one('uom.uom', string='Unit of Measure', compute='_compute_from_product_id_company_id',
+ store=True, states={'draft': [('readonly', False)], 'refused': [('readonly', False)]},
+ default=_default_product_uom_id, domain="[('category_id', '=', product_uom_category_id)]")
+ product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id', readonly=True)
+ unit_amount = fields.Float("Unit Price", compute='_compute_from_product_id_company_id', store=True, required=True, copy=True,
+ states={'draft': [('readonly', False)], 'reported': [('readonly', False)], 'refused': [('readonly', False)]}, digits='Product Price')
+ quantity = fields.Float(required=True, readonly=True, states={'draft': [('readonly', False)], 'reported': [('readonly', False)], 'refused': [('readonly', False)]}, digits='Product Unit of Measure', default=1)
+ tax_ids = fields.Many2many('account.tax', 'expense_tax', 'expense_id', 'tax_id',
+ compute='_compute_from_product_id_company_id', store=True, readonly=False,
+ domain="[('company_id', '=', company_id), ('type_tax_use', '=', 'purchase')]", string='Taxes')
+ untaxed_amount = fields.Float("Subtotal", store=True, compute='_compute_amount', digits='Account')
+ total_amount = fields.Monetary("Total", compute='_compute_amount', store=True, currency_field='currency_id', tracking=True)
+ amount_residual = fields.Monetary(string='Amount Due', compute='_compute_amount_residual')
+ company_currency_id = fields.Many2one('res.currency', string="Report Company Currency", related='sheet_id.currency_id', store=True, readonly=False)
+ total_amount_company = fields.Monetary("Total (Company Currency)", compute='_compute_total_amount_company', store=True, currency_field='company_currency_id')
+ company_id = fields.Many2one('res.company', string='Company', required=True, readonly=True, states={'draft': [('readonly', False)], 'refused': [('readonly', False)]}, default=lambda self: self.env.company)
+ # TODO make required in master (sgv)
+ currency_id = fields.Many2one('res.currency', string='Currency', readonly=True, states={'draft': [('readonly', False)], 'refused': [('readonly', False)]}, default=lambda self: self.env.company.currency_id)
+ analytic_account_id = fields.Many2one('account.analytic.account', string='Analytic Account', check_company=True)
+ analytic_tag_ids = fields.Many2many('account.analytic.tag', string='Analytic Tags', states={'post': [('readonly', True)], 'done': [('readonly', True)]}, domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
+ account_id = fields.Many2one('account.account', compute='_compute_from_product_id_company_id', store=True, readonly=False, string='Account',
+ default=_default_account_id, domain="[('internal_type', '=', 'other'), ('company_id', '=', company_id)]", help="An expense account is expected")
+ description = fields.Text('Notes...', readonly=True, states={'draft': [('readonly', False)], 'reported': [('readonly', False)], 'refused': [('readonly', False)]})
+ payment_mode = fields.Selection([
+ ("own_account", "Employee (to reimburse)"),
+ ("company_account", "Company")
+ ], default='own_account', tracking=True, states={'done': [('readonly', True)], 'approved': [('readonly', True)], 'reported': [('readonly', True)]}, string="Paid By")
+ attachment_number = fields.Integer('Number of Attachments', compute='_compute_attachment_number')
+ state = fields.Selection([
+ ('draft', 'To Submit'),
+ ('reported', 'Submitted'),
+ ('approved', 'Approved'),
+ ('done', 'Paid'),
+ ('refused', 'Refused')
+ ], compute='_compute_state', string='Status', copy=False, index=True, readonly=True, store=True, default='draft', help="Status of the expense.")
+ sheet_id = fields.Many2one('hr.expense.sheet', string="Expense Report", domain="[('employee_id', '=', employee_id), ('company_id', '=', company_id)]", readonly=True, copy=False)
+ reference = fields.Char("Bill Reference")
+ is_refused = fields.Boolean("Explicitly Refused by manager or accountant", readonly=True, copy=False)
+
+ 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')
+
+ sample = fields.Boolean()
+
+ @api.depends('sheet_id', 'sheet_id.account_move_id', 'sheet_id.state')
+ def _compute_state(self):
+ for expense in self:
+ if not expense.sheet_id or expense.sheet_id.state == 'draft':
+ expense.state = "draft"
+ elif expense.sheet_id.state == "cancel":
+ expense.state = "refused"
+ elif expense.sheet_id.state == "approve" or expense.sheet_id.state == "post":
+ expense.state = "approved"
+ elif not expense.sheet_id.account_move_id:
+ expense.state = "reported"
+ else:
+ expense.state = "done"
+
+ @api.depends('quantity', 'unit_amount', 'tax_ids', 'currency_id')
+ def _compute_amount(self):
+ for expense in self:
+ expense.untaxed_amount = expense.unit_amount * expense.quantity
+ taxes = expense.tax_ids.compute_all(expense.unit_amount, expense.currency_id, expense.quantity, expense.product_id, expense.employee_id.user_id.partner_id)
+ expense.total_amount = taxes.get('total_included')
+
+ @api.depends("sheet_id.account_move_id.line_ids")
+ def _compute_amount_residual(self):
+ for expense in self:
+ if not expense.sheet_id:
+ expense.amount_residual = expense.total_amount
+ continue
+ if not expense.currency_id or expense.currency_id == expense.company_id.currency_id:
+ residual_field = 'amount_residual'
+ else:
+ residual_field = 'amount_residual_currency'
+ payment_term_lines = expense.sheet_id.account_move_id.line_ids \
+ .filtered(lambda line: line.expense_id == self and line.account_internal_type in ('receivable', 'payable'))
+ expense.amount_residual = -sum(payment_term_lines.mapped(residual_field))
+
+ @api.depends('date', 'total_amount', 'company_currency_id')
+ def _compute_total_amount_company(self):
+ for expense in self:
+ amount = 0
+ if expense.company_currency_id:
+ date_expense = expense.date
+ amount = expense.currency_id._convert(
+ expense.total_amount, expense.company_currency_id,
+ expense.company_id, date_expense or fields.Date.today())
+ expense.total_amount_company = amount
+
+ def _compute_attachment_number(self):
+ attachment_data = self.env['ir.attachment'].read_group([('res_model', '=', 'hr.expense'), ('res_id', 'in', self.ids)], ['res_id'], ['res_id'])
+ attachment = dict((data['res_id'], data['res_id_count']) for data in attachment_data)
+ for expense in self:
+ expense.attachment_number = attachment.get(expense.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 == 'draft' or expense.sheet_id.state in ['draft', 'submit']:
+ expense.is_editable = True
+ elif expense.sheet_id.state == 'approve':
+ expense.is_editable = is_account_manager
+ else:
+ expense.is_editable = False
+
+ @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 == 'draft' or expense.sheet_id.state in ['draft', 'submit']:
+ expense.is_ref_editable = True
+ else:
+ expense.is_ref_editable = is_account_manager
+
+ @api.depends('product_id', 'company_id')
+ def _compute_from_product_id_company_id(self):
+ for expense in self.filtered('product_id'):
+ expense = expense.with_company(expense.company_id)
+ expense.name = expense.name or expense.product_id.display_name
+ if not expense.attachment_number or (expense.attachment_number and not expense.unit_amount):
+ expense.unit_amount = expense.product_id.price_compute('standard_price')[expense.product_id.id]
+ expense.product_uom_id = expense.product_id.uom_id
+ expense.tax_ids = expense.product_id.supplier_taxes_id.filtered(lambda tax: tax.company_id == expense.company_id) # taxes only from the same company
+ account = expense.product_id.product_tmpl_id._get_product_accounts()['expense']
+ if account:
+ expense.account_id = account
+
+ @api.depends('company_id')
+ def _compute_employee_id(self):
+ if not self.env.context.get('default_employee_id'):
+ for expense in self:
+ expense.employee_id = self.env.user.with_company(expense.company_id).employee_id
+
+ @api.onchange('product_id', 'date', 'account_id')
+ def _onchange_product_id_date_account_id(self):
+ rec = self.env['account.analytic.default'].sudo().account_get(
+ product_id=self.product_id.id,
+ account_id=self.account_id.id,
+ company_id=self.company_id.id,
+ date=self.date
+ )
+ self.analytic_account_id = self.analytic_account_id or rec.analytic_id.id
+ self.analytic_tag_ids = self.analytic_tag_ids or rec.analytic_tag_ids.ids
+
+ @api.constrains('product_id', 'product_uom_id')
+ def _check_product_uom_category(self):
+ if self.product_id and self.product_uom_id.category_id != self.product_id.uom_id.category_id:
+ raise UserError(_('Selected Unit of Measure does not belong to the same category as the product Unit of Measure.'))
+
+ def create_expense_from_attachments(self, attachment_ids=None, view_type='tree'):
+ ''' Create the expenses from files.
+ :return: An action redirecting to hr.expense tree/form view.
+ '''
+ if attachment_ids is None:
+ attachment_ids = []
+ attachments = self.env['ir.attachment'].browse(attachment_ids)
+ if not attachments:
+ raise UserError(_("No attachment was provided"))
+ expenses = self.env['hr.expense']
+
+ if any(attachment.res_id or attachment.res_model != 'hr.expense' for attachment in attachments):
+ raise UserError(_("Invalid attachments!"))
+
+ product = self.env['product.product'].search([('can_be_expensed', '=', True)])
+ if product:
+ product = product.filtered(lambda p: p.default_code == "EXP_GEN") or product[0]
+ else:
+ raise UserError(_("You need to have at least one product that can be expensed in your database to proceed!"))
+
+ for attachment in attachments:
+ expense = self.env['hr.expense'].create({
+ 'name': attachment.name.split('.')[0],
+ 'unit_amount': 0,
+ 'product_id': product.id
+ })
+ expense.message_post(body=_('Uploaded Attachment'))
+ attachment.write({
+ 'res_model': 'hr.expense',
+ 'res_id': expense.id,
+ })
+ attachment.register_as_main_attachment()
+ expenses += expense
+ if len(expenses) == 1:
+ return {
+ 'name': _('Generated Expense'),
+ 'view_mode': 'form',
+ 'res_model': 'hr.expense',
+ 'type': 'ir.actions.act_window',
+ 'views': [[False, 'form']],
+ 'res_id': expenses[0].id,
+ }
+ return {
+ 'name': _('Generated Expenses'),
+ 'domain': [('id', 'in', expenses.ids)],
+ 'res_model': 'hr.expense',
+ 'type': 'ir.actions.act_window',
+ 'views': [[False, view_type], [False, "form"]],
+ }
+
+ # ----------------------------------------
+ # ORM Overrides
+ # ----------------------------------------
+
+ def unlink(self):
+ for expense in self:
+ if expense.state in ['done', 'approved']:
+ raise UserError(_('You cannot delete a posted or approved expense.'))
+ return super(HrExpense, self).unlink()
+
+ def write(self, vals):
+ if 'tax_ids' in vals or 'analytic_account_id' 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.'))
+ return super(HrExpense, self).write(vals)
+
+ @api.model
+ def get_empty_list_help(self, help_message):
+ return super(HrExpense, self).get_empty_list_help(help_message + self._get_empty_list_mail_alias())
+
+ @api.model
+ def _get_empty_list_mail_alias(self):
+ use_mailgateway = self.env['ir.config_parameter'].sudo().get_param('hr_expense.use_mailgateway')
+ alias_record = use_mailgateway and self.env.ref('hr_expense.mail_alias_expense') or False
+ if alias_record and alias_record.alias_domain and alias_record.alias_name:
+ return """
+<p>
+Or send your receipts at <a href="mailto:%(email)s?subject=Lunch%%20with%%20customer%%3A%%20%%2412.32">%(email)s</a>.
+</p>""" % {'email': '%s@%s' % (alias_record.alias_name, alias_record.alias_domain)}
+ return ""
+
+ # ----------------------------------------
+ # Actions
+ # ----------------------------------------
+
+ def action_view_sheet(self):
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'view_mode': 'form',
+ 'res_model': 'hr.expense.sheet',
+ 'target': 'current',
+ 'res_id': self.sheet_id.id
+ }
+
+ def _create_sheet_from_expenses(self):
+ if any(expense.state != 'draft' or expense.sheet_id for expense in self):
+ raise UserError(_("You cannot report twice the same line!"))
+ if len(self.mapped('employee_id')) != 1:
+ raise UserError(_("You cannot report expenses for different employees in the same report."))
+ if any(not expense.product_id for expense in self):
+ raise UserError(_("You can not create report without product."))
+
+ todo = self.filtered(lambda x: x.payment_mode=='own_account') or self.filtered(lambda x: x.payment_mode=='company_account')
+ sheet = self.env['hr.expense.sheet'].create({
+ 'company_id': self.company_id.id,
+ 'employee_id': self[0].employee_id.id,
+ 'name': todo[0].name if len(todo) == 1 else '',
+ 'expense_line_ids': [(6, 0, todo.ids)]
+ })
+ return sheet
+
+ def action_submit_expenses(self):
+ sheet = self._create_sheet_from_expenses()
+ return {
+ 'name': _('New Expense Report'),
+ 'type': 'ir.actions.act_window',
+ 'view_mode': 'form',
+ 'res_model': 'hr.expense.sheet',
+ 'target': 'current',
+ 'res_id': sheet.id,
+ }
+
+ 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}
+ return res
+
+ # ----------------------------------------
+ # Business
+ # ----------------------------------------
+
+ def _prepare_move_values(self):
+ """
+ This function prepares move values related to an expense
+ """
+ self.ensure_one()
+ journal = self.sheet_id.bank_journal_id if self.payment_mode == 'company_account' else self.sheet_id.journal_id
+ account_date = self.sheet_id.accounting_date or self.date
+ move_values = {
+ 'journal_id': journal.id,
+ 'company_id': self.sheet_id.company_id.id,
+ 'date': account_date,
+ 'ref': self.sheet_id.name,
+ # 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': '/',
+ }
+ return move_values
+
+ def _get_account_move_by_sheet(self):
+ """ Return a mapping between the expense sheet of current expense and its account move
+ :returns dict where key is a sheet id, and value is an account move record
+ """
+ move_grouped_by_sheet = {}
+ for expense in self:
+ # create the move that will contain the accounting entries
+ if expense.sheet_id.id not in move_grouped_by_sheet:
+ move_vals = expense._prepare_move_values()
+ move = self.env['account.move'].with_context(default_journal_id=move_vals['journal_id']).create(move_vals)
+ move_grouped_by_sheet[expense.sheet_id.id] = move
+ else:
+ move = move_grouped_by_sheet[expense.sheet_id.id]
+ return move_grouped_by_sheet
+
+ def _get_expense_account_source(self):
+ self.ensure_one()
+ if self.account_id:
+ account = self.account_id
+ elif self.product_id:
+ account = self.product_id.product_tmpl_id.with_company(self.company_id)._get_product_accounts()['expense']
+ if not account:
+ raise UserError(
+ _("No Expense account found for the product %s (or for its category), please configure one.") % (self.product_id.name))
+ else:
+ account = self.env['ir.property'].with_company(self.company_id)._get('property_account_expense_categ_id', 'product.category')
+ if not account:
+ raise UserError(_('Please configure Default Expense account for Product expense: `property_account_expense_categ_id`.'))
+ return account
+
+ def _get_expense_account_destination(self):
+ self.ensure_one()
+ account_dest = self.env['account.account']
+ if self.payment_mode == 'company_account':
+ if not self.sheet_id.bank_journal_id.payment_credit_account_id:
+ raise UserError(_("No Outstanding Payments Account found for the %s journal, please configure one.") % (self.sheet_id.bank_journal_id.name))
+ account_dest = self.sheet_id.bank_journal_id.payment_credit_account_id.id
+ else:
+ if not self.employee_id.sudo().address_home_id:
+ raise UserError(_("No Home Address found for the employee %s, please configure one.") % (self.employee_id.name))
+ partner = self.employee_id.sudo().address_home_id.with_company(self.company_id)
+ account_dest = partner.property_account_payable_id.id or partner.parent_id.property_account_payable_id.id
+ return account_dest
+
+ def _get_account_move_line_values(self):
+ move_line_values_by_expense = {}
+ for expense in self:
+ move_line_name = expense.employee_id.name + ': ' + expense.name.split('\n')[0][:64]
+ account_src = expense._get_expense_account_source()
+ account_dst = expense._get_expense_account_destination()
+ account_date = expense.sheet_id.accounting_date or expense.date or fields.Date.context_today(expense)
+
+ company_currency = expense.company_id.currency_id
+
+ move_line_values = []
+ taxes = expense.tax_ids.with_context(round=True).compute_all(expense.unit_amount, expense.currency_id, expense.quantity, expense.product_id)
+ total_amount = 0.0
+ total_amount_currency = 0.0
+ partner_id = expense.employee_id.sudo().address_home_id.commercial_partner_id.id
+
+ # source move line
+ balance = expense.currency_id._convert(taxes['total_excluded'], company_currency, expense.company_id, account_date)
+ amount_currency = taxes['total_excluded']
+ move_line_src = {
+ 'name': move_line_name,
+ 'quantity': expense.quantity or 1,
+ 'debit': balance if balance > 0 else 0,
+ 'credit': -balance if balance < 0 else 0,
+ 'amount_currency': amount_currency,
+ 'account_id': account_src.id,
+ 'product_id': expense.product_id.id,
+ 'product_uom_id': expense.product_uom_id.id,
+ 'analytic_account_id': expense.analytic_account_id.id,
+ 'analytic_tag_ids': [(6, 0, expense.analytic_tag_ids.ids)],
+ 'expense_id': expense.id,
+ 'partner_id': partner_id,
+ 'tax_ids': [(6, 0, expense.tax_ids.ids)],
+ 'tax_tag_ids': [(6, 0, taxes['base_tags'])],
+ 'currency_id': expense.currency_id.id,
+ }
+ move_line_values.append(move_line_src)
+ total_amount -= balance
+ total_amount_currency -= move_line_src['amount_currency']
+
+ # taxes move lines
+ for tax in taxes['taxes']:
+ balance = expense.currency_id._convert(tax['amount'], company_currency, expense.company_id, account_date)
+ amount_currency = tax['amount']
+
+ if tax['tax_repartition_line_id']:
+ rep_ln = self.env['account.tax.repartition.line'].browse(tax['tax_repartition_line_id'])
+ base_amount = self.env['account.move']._get_base_amount_to_display(tax['base'], rep_ln)
+ base_amount = expense.currency_id._convert(base_amount, company_currency, expense.company_id, account_date)
+ else:
+ base_amount = None
+
+ move_line_tax_values = {
+ 'name': tax['name'],
+ 'quantity': 1,
+ 'debit': balance if balance > 0 else 0,
+ 'credit': -balance if balance < 0 else 0,
+ 'amount_currency': amount_currency,
+ 'account_id': tax['account_id'] or move_line_src['account_id'],
+ 'tax_repartition_line_id': tax['tax_repartition_line_id'],
+ 'tax_tag_ids': tax['tag_ids'],
+ 'tax_base_amount': base_amount,
+ 'expense_id': expense.id,
+ 'partner_id': partner_id,
+ 'currency_id': expense.currency_id.id,
+ 'analytic_account_id': expense.analytic_account_id.id if tax['analytic'] else False,
+ 'analytic_tag_ids': [(6, 0, expense.analytic_tag_ids.ids)] if tax['analytic'] else False,
+ }
+ total_amount -= balance
+ total_amount_currency -= move_line_tax_values['amount_currency']
+ move_line_values.append(move_line_tax_values)
+
+ # destination move line
+ move_line_dst = {
+ 'name': move_line_name,
+ 'debit': total_amount > 0 and total_amount,
+ 'credit': total_amount < 0 and -total_amount,
+ 'account_id': account_dst,
+ 'date_maturity': account_date,
+ 'amount_currency': total_amount_currency,
+ 'currency_id': expense.currency_id.id,
+ 'expense_id': expense.id,
+ 'partner_id': partner_id,
+ }
+ move_line_values.append(move_line_dst)
+
+ move_line_values_by_expense[expense.id] = move_line_values
+ return move_line_values_by_expense
+
+ def action_move_create(self):
+ '''
+ main function that is called when trying to create the accounting entries related to an expense
+ '''
+ move_group_by_sheet = self._get_account_move_by_sheet()
+
+ move_line_values_by_expense = self._get_account_move_line_values()
+
+ for expense in self:
+ # get the account move of the related sheet
+ move = move_group_by_sheet[expense.sheet_id.id]
+
+ # get move line values
+ move_line_values = move_line_values_by_expense.get(expense.id)
+
+ # link move lines to move, and move to expense sheet
+ move.write({'line_ids': [(0, 0, line) for line in move_line_values]})
+ expense.sheet_id.write({'account_move_id': move.id})
+
+ if expense.payment_mode == 'company_account':
+ expense.sheet_id.paid_expense_sheets()
+
+ # post the moves
+ for move in move_group_by_sheet.values():
+ move._post()
+
+ return move_group_by_sheet
+
+ def refuse_expense(self, reason):
+ self.write({'is_refused': True})
+ self.sheet_id.write({'state': 'cancel'})
+ self.sheet_id.message_post_with_view('hr_expense.hr_expense_template_refuse_reason',
+ values={'reason': reason, 'is_sheet': False, 'name': self.name})
+
+ @api.model
+ def get_expense_dashboard(self):
+ expense_state = {
+ 'draft': {
+ 'description': _('to report'),
+ 'amount': 0.0,
+ 'currency': self.env.company.currency_id.id,
+ },
+ 'reported': {
+ 'description': _('under validation'),
+ 'amount': 0.0,
+ 'currency': self.env.company.currency_id.id,
+ },
+ 'approved': {
+ 'description': _('to be reimbursed'),
+ 'amount': 0.0,
+ 'currency': self.env.company.currency_id.id,
+ }
+ }
+ if not self.env.user.employee_ids:
+ return expense_state
+ target_currency = self.env.company.currency_id
+ expenses = self.read_group(
+ [
+ ('employee_id', 'in', self.env.user.employee_ids.ids),
+ ('payment_mode', '=', 'own_account'),
+ ('state', 'in', ['draft', 'reported', 'approved'])
+ ], ['total_amount', 'currency_id', 'state'], ['state', 'currency_id'], lazy=False)
+ for expense in expenses:
+ state = expense['state']
+ currency = self.env['res.currency'].browse(expense['currency_id'][0]) if expense['currency_id'] else target_currency
+ amount = currency._convert(
+ expense['total_amount'], target_currency, self.env.company, fields.Date.today())
+ expense_state[state]['amount'] += amount
+ return expense_state
+
+ # ----------------------------------------
+ # Mail Thread
+ # ----------------------------------------
+
+ @api.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)
+
+ expense_description = msg_dict.get('subject', '')
+
+ if employee.user_id:
+ company = employee.user_id.company_id
+ currencies = company.currency_id | employee.user_id.company_ids.mapped('currency_id')
+ else:
+ company = employee.company_id
+ currencies = company.currency_id
+
+ if not company: # ultimate fallback, since company_id is required on expense
+ company = self.env.company
+
+ # The expenses alias is the same for all companies, we need to set the proper context
+ # To select the product account
+ self = self.with_company(company)
+
+ product, price, currency_id, expense_description = self._parse_expense_subject(expense_description, currencies)
+ vals = {
+ 'employee_id': employee.id,
+ 'name': expense_description,
+ 'unit_amount': 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],
+ 'quantity': 1,
+ 'company_id': company.id,
+ 'currency_id': currency_id.id
+ }
+
+ account = product.product_tmpl_id._get_product_accounts()['expense']
+ if account:
+ vals['account_id'] = account.id
+
+ expense = super(HrExpense, self).message_new(msg_dict, dict(custom_values or {}, **vals))
+ self._send_expense_success_mail(msg_dict, expense)
+ return expense
+
+ @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
+ """
+ product_code = expense_description.split(' ')[0]
+ product = self.env['product.product'].search([('can_be_expensed', '=', True), ('default_code', '=ilike', product_code)], limit=1)
+ if product:
+ expense_description = expense_description.replace(product_code, '', 1)
+
+ return product, expense_description
+
+ @api.model
+ def _parse_price(self, expense_description, currencies):
+ """ Return price, currency and updated description """
+ symbols, symbols_pattern, float_pattern = [], '', '[+-]?(\d+[.,]?\d*)'
+ price = 0.0
+ for currency in currencies:
+ symbols.append(re.escape(currency.symbol))
+ symbols.append(re.escape(currency.name))
+ symbols_pattern = '|'.join(symbols)
+ price_pattern = "((%s)?\s?%s\s?(%s)?)" % (symbols_pattern, float_pattern, symbols_pattern)
+ matches = re.findall(price_pattern, expense_description)
+ 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
+ full_str = match[0]
+ currency_str = match[1] or match[3]
+ price = match[2].replace(',', '.')
+
+ if currency_str:
+ currency = currencies.filtered(lambda c: currency_str in [c.symbol, c.name])[0]
+ currency = currency or currencies[0]
+ 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
+
+ @api.model
+ def _parse_expense_subject(self, expense_description, currencies):
+ """ 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.
+
+ When parsing, only consider currencies passed as parameter.
+ This will fetch currency in symbol($) or ISO name (USD).
+
+ Some valid examples:
+ Travel by Air [TICKET] USD 1205.91
+ TICKET $1205.91 Travel by Air
+ Extra expenses 29.10EUR [EXTRA]
+ """
+ product, expense_description = self._parse_product(expense_description)
+ price, currency_id, expense_description = self._parse_price(expense_description, currencies)
+
+ 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'
+ expense_template = self.env.ref(mail_template_id)
+ rendered_body = expense_template._render({'expense': expense}, engine='ir.qweb')
+ 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(
+ partner_ids=expense.employee_id.user_id.partner_id.ids,
+ subject='Re: %s' % msg_dict.get('subject', ''),
+ body=body,
+ subtype_id=self.env.ref('mail.mt_note').id,
+ email_layout_xmlid='mail.mail_notification_light',
+ )
+ else:
+ self.env['mail.mail'].sudo().create({
+ 'email_from': self.env.user.email_formatted,
+ 'author_id': self.env.user.partner_id.id,
+ 'body_html': body,
+ 'subject': 'Re: %s' % msg_dict.get('subject', ''),
+ 'email_to': msg_dict.get('email_from', False),
+ 'auto_delete': True,
+ 'references': msg_dict.get('message_id'),
+ }).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', '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. """
+ default_company_id = self.default_get(['company_id'])['company_id']
+ journal = self.env['account.journal'].search([('type', '=', 'purchase'), ('company_id', '=', default_company_id)], limit=1)
+ return journal.id
+
+ @api.model
+ def _default_bank_journal_id(self):
+ default_company_id = self.default_get(['company_id'])['company_id']
+ return self.env['account.journal'].search([('type', 'in', ['cash', 'bank']), ('company_id', '=', default_company_id)], limit=1)
+
+ name = fields.Char('Expense Report Summary', required=True, tracking=True)
+ expense_line_ids = fields.One2many('hr.expense', 'sheet_id', string='Expense Lines', copy=False)
+ state = fields.Selection([
+ ('draft', 'Draft'),
+ ('submit', 'Submitted'),
+ ('approve', 'Approved'),
+ ('post', 'Posted'),
+ ('done', 'Paid'),
+ ('cancel', 'Refused')
+ ], string='Status', index=True, readonly=True, tracking=True, copy=False, default='draft', required=True, help='Expense Report State')
+ employee_id = fields.Many2one('hr.employee', string="Employee", required=True, readonly=True, tracking=True, states={'draft': [('readonly', False)]}, default=_default_employee_id, check_company=True, domain= lambda self: self.env['hr.expense']._get_employee_id_domain())
+ address_id = fields.Many2one('res.partner', compute='_compute_from_employee_id', store=True, readonly=False, copy=True, string="Employee Home Address", check_company=True)
+ payment_mode = fields.Selection(related='expense_line_ids.payment_mode', default='own_account', readonly=True, string="Paid By", tracking=True)
+ user_id = fields.Many2one('res.users', 'Manager', compute='_compute_from_employee_id', store=True, readonly=True, copy=False, states={'draft': [('readonly', False)]}, tracking=True, domain=lambda self: [('groups_id', 'in', self.env.ref('hr_expense.group_hr_expense_team_approver').id)])
+ total_amount = fields.Monetary('Total Amount', currency_field='currency_id', compute='_compute_amount', store=True, tracking=True)
+ amount_residual = fields.Monetary(
+ string="Amount Due", store=True,
+ currency_field='currency_id',
+ compute='_compute_amount_residual')
+ company_id = fields.Many2one('res.company', string='Company', required=True, readonly=True, states={'draft': [('readonly', False)]}, default=lambda self: self.env.company)
+ currency_id = fields.Many2one('res.currency', string='Currency', readonly=True, states={'draft': [('readonly', False)]}, default=lambda self: self.env.company.currency_id)
+ attachment_number = fields.Integer(compute='_compute_attachment_number', string='Number of Attachments')
+ journal_id = fields.Many2one('account.journal', string='Expense Journal', states={'done': [('readonly', True)], 'post': [('readonly', True)]}, check_company=True, domain="[('type', '=', 'purchase'), ('company_id', '=', company_id)]",
+ default=_default_journal_id, help="The journal used when the expense is done.")
+ bank_journal_id = fields.Many2one('account.journal', string='Bank Journal', states={'done': [('readonly', True)], 'post': [('readonly', True)]}, check_company=True, domain="[('type', 'in', ['cash', 'bank']), ('company_id', '=', company_id)]",
+ default=_default_bank_journal_id, help="The payment method used when the expense is paid by the company.")
+ accounting_date = fields.Date("Accounting Date")
+ account_move_id = fields.Many2one('account.move', string='Journal Entry', ondelete='restrict', copy=False, readonly=True)
+ department_id = fields.Many2one('hr.department', compute='_compute_from_employee_id', store=True, readonly=False, copy=False, string='Department', states={'post': [('readonly', True)], 'done': [('readonly', True)]})
+ is_multiple_currency = fields.Boolean("Handle lines with different currencies", compute='_compute_is_multiple_currency')
+ can_reset = fields.Boolean('Can Reset', compute='_compute_can_reset')
+
+ _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')
+ def _compute_amount(self):
+ for sheet in self:
+ sheet.total_amount = sum(sheet.expense_line_ids.mapped('total_amount_company'))
+
+ @api.depends(
+ 'currency_id',
+ 'account_move_id.line_ids.amount_residual',
+ 'account_move_id.line_ids.amount_residual_currency',
+ 'account_move_id.line_ids.account_internal_type',)
+ def _compute_amount_residual(self):
+ for sheet in self:
+ if sheet.currency_id == sheet.company_id.currency_id:
+ residual_field = 'amount_residual'
+ else:
+ residual_field = 'amount_residual_currency'
+
+ payment_term_lines = sheet.account_move_id.line_ids\
+ .filtered(lambda line: line.account_internal_type in ('receivable', 'payable'))
+ sheet.amount_residual = -sum(payment_term_lines.mapped(residual_field))
+
+ def _compute_attachment_number(self):
+ for sheet in self:
+ sheet.attachment_number = sum(sheet.expense_line_ids.mapped('attachment_number'))
+
+ @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
+
+ 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('employee_id')
+ def _compute_from_employee_id(self):
+ for sheet in self:
+ sheet.address_id = sheet.employee_id.sudo().address_home_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.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(_("Expenses must be paid by the same entity (Company or employee)."))
+
+ @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.'))
+
+ @api.model
+ def create(self, vals):
+ sheet = super(HrExpenseSheet, self.with_context(mail_create_nosubscribe=True, mail_auto_subscribe_no_notify=True)).create(vals)
+ sheet.activity_update()
+ return sheet
+
+ def unlink(self):
+ for expense in self:
+ if expense.state in ['post', 'done']:
+ raise UserError(_('You cannot delete a posted or paid expense.'))
+ super(HrExpenseSheet, self).unlink()
+
+ # --------------------------------------------
+ # Mail Thread
+ # --------------------------------------------
+
+ def _track_subtype(self, init_values):
+ self.ensure_one()
+ if 'state' in init_values and self.state == 'approve':
+ return self.env.ref('hr_expense.mt_expense_approved')
+ elif 'state' in init_values and self.state == 'cancel':
+ return self.env.ref('hr_expense.mt_expense_refused')
+ elif 'state' in init_values and self.state == 'done':
+ return self.env.ref('hr_expense.mt_expense_paid')
+ return super(HrExpenseSheet, self)._track_subtype(init_values)
+
+ def _message_auto_subscribe_followers(self, updated_values, subtype_ids):
+ res = super(HrExpenseSheet, self)._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
+
+ # --------------------------------------------
+ # Actions
+ # --------------------------------------------
+
+ def action_sheet_move_create(self):
+ samples = self.mapped('expense_line_ids.sample')
+ if samples.count(True):
+ if samples.count(False):
+ raise UserError(_("You can't mix sample expenses and regular ones"))
+ self.write({'state': 'post'})
+ return
+
+ 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(_("Expenses must have an expense journal specified to generate accounting entries."))
+
+ expense_line_ids = self.mapped('expense_line_ids')\
+ .filtered(lambda r: not float_is_zero(r.total_amount, precision_rounding=(r.currency_id or self.env.company.currency_id).rounding))
+ res = expense_line_ids.action_move_create()
+ for sheet in self.filtered(lambda s: not s.accounting_date):
+ sheet.accounting_date = sheet.account_move_id.date
+ to_post = self.filtered(lambda sheet: sheet.payment_mode == 'own_account' and sheet.expense_line_ids)
+ to_post.write({'state': 'post'})
+ (self - to_post).write({'state': 'done'})
+ self.activity_update()
+ return res
+
+ def action_get_attachment_view(self):
+ res = self.env['ir.actions.act_window']._for_xml_id('base.action_attachment')
+ res['domain'] = [('res_model', '=', 'hr.expense'), ('res_id', 'in', self.expense_line_ids.ids)]
+ res['context'] = {
+ 'default_res_model': 'hr.expense.sheet',
+ 'default_res_id': self.id,
+ 'create': False,
+ 'edit': False,
+ }
+ return res
+
+ # --------------------------------------------
+ # Business
+ # --------------------------------------------
+
+ def set_to_paid(self):
+ self.write({'state': 'done'})
+
+ def action_submit_sheet(self):
+ self.write({'state': 'submit'})
+ self.activity_update()
+
+ def approve_expense_sheets(self):
+ if not self.user_has_groups('hr_expense.group_hr_expense_team_approver'):
+ raise UserError(_("Only Managers and HR Officers can approve expenses"))
+ elif not self.user_has_groups('hr_expense.group_hr_expense_manager'):
+ current_managers = self.employee_id.expense_manager_id | self.employee_id.parent_id.user_id | self.employee_id.department_id.manager_id.user_id
+
+ if self.employee_id.user_id == self.env.user:
+ raise UserError(_("You cannot approve your own expenses"))
+
+ if not self.env.user in current_managers and not self.user_has_groups('hr_expense.group_hr_expense_user') and self.employee_id.expense_manager_id != self.env.user:
+ raise UserError(_("You can only approve your department expenses"))
+
+ notification = {
+ 'type': 'ir.actions.client',
+ 'tag': 'display_notification',
+ 'params': {
+ 'title': _('There are no expense reports to approve.'),
+ 'type': 'warning',
+ 'sticky': False, #True/False will display for few seconds if false
+ },
+ }
+ filtered_sheet = self.filtered(lambda s: s.state in ['submit', 'draft'])
+ if not filtered_sheet:
+ return notification
+ for sheet in filtered_sheet:
+ sheet.write({'state': 'approve', 'user_id': sheet.user_id.id or self.env.user.id})
+ notification['params'].update({
+ 'title': _('The expense reports were successfully approved.'),
+ 'type': 'success',
+ 'next': {'type': 'ir.actions.act_window_close'},
+ })
+
+ self.activity_update()
+ return notification
+
+ def paid_expense_sheets(self):
+ self.write({'state': 'done'})
+
+ def refuse_sheet(self, reason):
+ if not self.user_has_groups('hr_expense.group_hr_expense_team_approver'):
+ raise UserError(_("Only Managers and HR Officers can approve expenses"))
+ elif not self.user_has_groups('hr_expense.group_hr_expense_manager'):
+ current_managers = self.employee_id.expense_manager_id | self.employee_id.parent_id.user_id | self.employee_id.department_id.manager_id.user_id
+
+ if self.employee_id.user_id == self.env.user:
+ raise UserError(_("You cannot refuse your own expenses"))
+
+ if not self.env.user in current_managers and not self.user_has_groups('hr_expense.group_hr_expense_user') and self.employee_id.expense_manager_id != self.env.user:
+ raise UserError(_("You can only refuse your department expenses"))
+
+ self.write({'state': 'cancel'})
+ for sheet in self:
+ sheet.message_post_with_view('hr_expense.hr_expense_template_refuse_reason', values={'reason': reason, 'is_sheet': True, 'name': sheet.name})
+ self.activity_update()
+
+ def reset_expense_sheets(self):
+ if not self.can_reset:
+ raise UserError(_("Only HR Officers or the concerned employee can reset to draft."))
+ self.mapped('expense_line_ids').write({'is_refused': False})
+ self.write({'state': 'draft'})
+ self.activity_update()
+ return True
+
+ def _get_responsible_for_approval(self):
+ if self.user_id:
+ return self.user_id
+ elif self.employee_id.parent_id.user_id:
+ return self.employee_id.parent_id.user_id
+ elif 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 activity_update(self):
+ for expense_report in self.filtered(lambda hol: hol.state == 'submit'):
+ self.activity_schedule(
+ 'hr_expense.mail_act_expense_approval',
+ user_id=expense_report.sudo()._get_responsible_for_approval().id or self.env.user.id)
+ self.filtered(lambda hol: hol.state == 'approve').activity_feedback(['hr_expense.mail_act_expense_approval'])
+ self.filtered(lambda hol: hol.state in ('draft', 'cancel')).activity_unlink(['hr_expense.mail_act_expense_approval'])
+
+ def action_register_payment(self):
+ ''' Open the account.payment.register wizard to pay the selected journal entries.
+ :return: An action opening the account.payment.register wizard.
+ '''
+ return {
+ 'name': _('Register Payment'),
+ 'res_model': 'account.payment.register',
+ 'view_mode': 'form',
+ 'context': {
+ 'active_model': 'account.move',
+ 'active_ids': self.account_move_id.ids,
+ },
+ 'target': 'new',
+ 'type': 'ir.actions.act_window',
+ }
diff --git a/addons/hr_expense/models/product_template.py b/addons/hr_expense/models/product_template.py
new file mode 100644
index 00000000..d053a800
--- /dev/null
+++ b/addons/hr_expense/models/product_template.py
@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+
+
+class ProductTemplate(models.Model):
+ _inherit = "product.template"
+
+ can_be_expensed = fields.Boolean(string="Can be Expensed", compute='_compute_can_be_expensed',
+ store=True, readonly=False, help="Specify whether the product can be selected in an expense.")
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ for vals in vals_list:
+ # When creating an expense product on the fly, you don't expect to
+ # have taxes on it
+ if vals.get('can_be_expensed', False) and not self.env.context.get('import_file'):
+ vals.update({'supplier_taxes_id': False})
+ return super(ProductTemplate, self).create(vals_list)
+
+ @api.depends('type')
+ def _compute_can_be_expensed(self):
+ self.filtered(lambda p: p.type not in ['consu', 'service']).update({'can_be_expensed': False})
diff --git a/addons/hr_expense/models/res_config_settings.py b/addons/hr_expense/models/res_config_settings.py
new file mode 100644
index 00000000..82119de3
--- /dev/null
+++ b/addons/hr_expense/models/res_config_settings.py
@@ -0,0 +1,32 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models
+
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = 'res.config.settings'
+
+ expense_alias_prefix = fields.Char('Default Alias Name for Expenses', compute='_compute_expense_alias_prefix',
+ store=True, readonly=False)
+ use_mailgateway = fields.Boolean(string='Let your employees record expenses by email',
+ config_parameter='hr_expense.use_mailgateway')
+
+ module_hr_payroll_expense = fields.Boolean(string='Reimburse Expenses in Payslip')
+ module_hr_expense_extract = fields.Boolean(string='Send bills to OCR to generate expenses')
+
+
+ @api.model
+ def get_values(self):
+ res = super(ResConfigSettings, self).get_values()
+ res.update(
+ expense_alias_prefix=self.env.ref('hr_expense.mail_alias_expense').alias_name,
+ )
+ return res
+
+ def set_values(self):
+ super(ResConfigSettings, self).set_values()
+ self.env.ref('hr_expense.mail_alias_expense').write({'alias_name': self.expense_alias_prefix})
+
+ @api.depends('use_mailgateway')
+ def _compute_expense_alias_prefix(self):
+ self.filtered(lambda w: not w.use_mailgateway).update({'expense_alias_prefix': False})