diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/hr_expense/models | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/hr_expense/models')
| -rw-r--r-- | addons/hr_expense/models/__init__.py | 9 | ||||
| -rw-r--r-- | addons/hr_expense/models/account_journal_dashboard.py | 44 | ||||
| -rw-r--r-- | addons/hr_expense/models/account_move_line.py | 26 | ||||
| -rw-r--r-- | addons/hr_expense/models/hr_department.py | 16 | ||||
| -rw-r--r-- | addons/hr_expense/models/hr_employee.py | 57 | ||||
| -rw-r--r-- | addons/hr_expense/models/hr_expense.py | 1031 | ||||
| -rw-r--r-- | addons/hr_expense/models/product_template.py | 24 | ||||
| -rw-r--r-- | addons/hr_expense/models/res_config_settings.py | 32 |
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}) |
