diff --git a/addons/hr_expense/data/hr_expense_demo.xml b/addons/hr_expense/data/hr_expense_demo.xml index a97ed534ea7..bdc3f1ff19a 100644 --- a/addons/hr_expense/data/hr_expense_demo.xml +++ b/addons/hr_expense/data/hr_expense_demo.xml @@ -36,7 +36,7 @@ - + @@ -45,7 +45,7 @@ - + @@ -53,17 +53,15 @@ Travel by car - - + BreakFast - - + @@ -77,9 +75,8 @@ - + - @@ -89,9 +86,8 @@ - + - @@ -101,9 +97,8 @@ - + - @@ -113,8 +108,7 @@ - - + @@ -130,10 +124,9 @@ - - + @@ -143,7 +136,7 @@ - + @@ -160,8 +153,7 @@ - - + @@ -171,8 +163,7 @@ - - + @@ -182,7 +173,7 @@ - + @@ -199,7 +190,7 @@ - + @@ -209,7 +200,7 @@ - + @@ -220,7 +211,7 @@ - + diff --git a/addons/hr_expense/data/mail_templates.xml b/addons/hr_expense/data/mail_templates.xml index 7ec0b0df84c..4be5fc7c864 100644 --- a/addons/hr_expense/data/mail_templates.xml +++ b/addons/hr_expense/data/mail_templates.xml @@ -24,7 +24,7 @@

The first word of the email subject did not correspond to any category code. You'll have to set the category manually on the expense.

- Price: + Price:


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

- +
diff --git a/addons/hr_expense/tests/common.py b/addons/hr_expense/tests/common.py index 88ee589f627..f8bddd13d01 100644 --- a/addons/hr_expense/tests/common.py +++ b/addons/hr_expense/tests/common.py @@ -1,8 +1,9 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from datetime import datetime + +from freezegun import freeze_time from odoo import Command - from odoo.addons.account.tests.common import AccountTestInvoicingCommon from odoo.addons.mail.tests.common import mail_new_test_user @@ -22,7 +23,7 @@ class TestExpenseCommon(AccountTestInvoicingCommon): email='expense_user_employee@example.com', notification_type='email', groups='base.group_user', - company_ids=[(6, 0, cls.env.companies.ids)], + company_ids=[Command.set(cls.env.companies.ids)], ) cls.expense_user_manager = mail_new_test_user( cls.env, @@ -31,24 +32,15 @@ class TestExpenseCommon(AccountTestInvoicingCommon): email='expense_manager_1@example.com', notification_type='email', groups='base.group_user,hr_expense.group_hr_expense_manager', - company_ids=[(6, 0, cls.env.companies.ids)], + company_ids=[Command.set(cls.env.companies.ids)], ) cls.expense_employee = cls.env['hr.employee'].create({ 'name': 'expense_employee', 'user_id': cls.expense_user_employee.id, 'work_contact_id': cls.expense_user_employee.partner_id.id, - 'address_id': cls.expense_user_employee.partner_id.id, }) - cls.product_zero_cost = cls.env['product.product'].create({ - 'name': 'General', - 'default_code': 'EXP_GEN', - 'standard_price': 0.0, - 'can_be_expensed': True, - }) - - # Allow the current accounting user to access the expenses. cls.env.user.groups_id |= group_expense_manager @@ -63,6 +55,7 @@ class TestExpenseCommon(AccountTestInvoicingCommon): 'plan_id': cls.analytic_plan.id, }) + # Create product without cost cls.product_c = cls.env['product.product'].create({ 'name': 'product_c with no cost', 'uom_id': cls.env.ref('uom.product_uom_dozen').id, @@ -72,7 +65,60 @@ class TestExpenseCommon(AccountTestInvoicingCommon): 'taxes_id': [Command.set((cls.tax_sale_a + cls.tax_sale_b).ids)], 'supplier_taxes_id': [Command.set((cls.tax_purchase_a + cls.tax_purchase_b).ids)], 'can_be_expensed': True, + 'default_code': 'product_c', }) - # Ensure products can be expensed. + # Ensure Invoicing tests products can be expensed and their code is properly set. (cls.product_a + cls.product_b).write({'can_be_expensed': True}) + cls.product_a.default_code = 'product_a' + cls.product_b.default_code = 'product_b' + + cls.frozen_today = datetime(year=2022, month=1, day=25, hour=0, minute=0, second=0) + + # create expense account + cls.expense_account = cls.env['account.account'].create({ + 'code': '610010', + 'name': 'Expense Account 1' + }) + + def create_expense_report(self, values=None): + values = values or {} + default_values = { + 'name': 'Test Expense Report', + 'employee_id': self.expense_employee.id, + 'company_id': self.company_data['company'].id, + 'expense_line_ids': [Command.create({ + 'employee_id': self.expense_employee.id, + 'product_id': self.product_c.id, + 'total_amount_currency': 1000.00, + 'tax_ids': [Command.set(self.tax_purchase_a.ids)], + 'date': self.frozen_today, + 'company_id': self.company_data['company'].id, + 'currency_id': self.company_data['currency'].id, + })] + } + return self.env['hr.expense.sheet'].create({**default_values, **values}) + + def create_expense(self, values=None): + values = values or {} + default_values = { + 'employee_id': self.expense_employee.id, + 'product_id': self.product_c.id, + 'total_amount_currency': 1000.00, + 'tax_ids': [Command.set(self.tax_purchase_a.ids)], + 'date': self.frozen_today, + 'company_id': self.company_data['company'].id, + 'currency_id': self.company_data['currency'].id, + } + return self.env['hr.expense'].create({**default_values, **values}) + + def get_new_payment(self, expense_sheet, amount): + """ Helper to create payments """ + ctx = {'active_model': 'account.move', 'active_ids': expense_sheet.account_move_ids.ids} + with freeze_time(self.frozen_today): + payment_register = self.env['account.payment.register'].with_context(**ctx).create({ + 'amount': amount, + 'journal_id': self.company_data['default_journal_bank'].id, + 'payment_method_line_id': self.inbound_payment_method_line.id, + }) + return payment_register._create_payments() diff --git a/addons/hr_expense/tests/test_expenses.py b/addons/hr_expense/tests/test_expenses.py index a8b067158b3..ce8a8655319 100644 --- a/addons/hr_expense/tests/test_expenses.py +++ b/addons/hr_expense/tests/test_expenses.py @@ -1,706 +1,565 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. +from datetime import date + from freezegun import freeze_time +from odoo import Command from odoo.addons.hr_expense.tests.common import TestExpenseCommon +from odoo.exceptions import UserError from odoo.tests import tagged, Form from odoo.tools.misc import formatLang -from odoo import fields, Command -from odoo.exceptions import UserError @tagged('-at_install', 'post_install') class TestExpenses(TestExpenseCommon): - - def test_expense_sheet_changing_employee(self): - """ Test changing an employee on the expense that is linked with the sheet. - - In case sheet has only one expense linked with it, than changing an employee - on expense should trigger changing an employee on the sheet itself. - - In case sheet has more than one expense linked with it, than changing an employee - on one of the expenses, should cause unlinking the expense from the sheet.""" - - employee = self.env['hr.employee'].create({ - 'name': 'Gabriel Iglesias', - }) - - expense1 = self.env['hr.expense'].create({ - 'name': 'Dinner with client - Expenses', - 'employee_id': self.expense_employee.id, - 'product_id': self.product_c.id, - 'total_amount': 350.00, - }) - - expense2 = self.env['hr.expense'].create({ - 'name': 'Team building at Huy', - 'employee_id': employee.id, - 'product_id': self.product_c.id, - 'total_amount': 2500.00, - }) - - expense_sheet = self.env['hr.expense.sheet'].create({ - 'name': 'Expense for Jannette', - 'employee_id': self.expense_employee.id, - 'expense_line_ids': expense1, - }) - - expense1.employee_id = employee - self.assertEqual(expense_sheet.employee_id, employee, 'Employee should have changed on the sheet') - - expense_sheet.expense_line_ids |= expense2 - expense2.employee_id = self.expense_employee.id - self.assertEqual(expense2.sheet_id.id, False, 'Sheet should be unlinked from the expense') - - def test_expense_sheet_paid_employee(self): - ''' Test expense sheet paid by employee flow''' - - def get_payment(expense_sheet, amount): - ctx = {'active_model': 'account.move', 'active_ids': expense_sheet.account_move_ids.ids} - payment_register = self.env['account.payment.register'].with_context(**ctx).create({ - 'amount': amount, - 'journal_id': self.company_data['default_journal_bank'].id, - 'payment_method_line_id': self.inbound_payment_method_line.id, - }) - return payment_register._create_payments() - - expense_sheet = self.env['hr.expense.sheet'].create({ + ############################################# + # Test Expense flows + ############################################# + def test_expense_main_flow(self): + """ + Test the main flows of expense + This includes: + - Approval flows for expense paid by company and employee up to reconciliation + - accounting_date computation and override + - price_unit, total_amount_currency and quantity computation + - Split payments into one payment per expense when paid by company + - Override account on expense + - Payment states and payment terms + - Unlinking payments reverts to approved state + - Cannot delete an analytic account if linked to an expense + """ + # pylint: disable=bad-whitespace + self.expense_employee.user_partner_id.property_supplier_payment_term_id = self.env.ref('account.account_payment_term_30days') + expense_sheet_by_employee = self.create_expense_report({ 'name': 'Expense for John Smith', + 'accounting_date': '2021-10-10', # This should be the date set as the accounting_date + 'expense_line_ids': [Command.create({ + 'name': 'PA 2*800 + 15%', # Taxes are included + 'employee_id': self.expense_employee.id, + 'account_id': self.expense_account.id, # Test with a specific account override + 'product_id': self.product_a.id, + 'quantity': 2, + 'payment_mode': 'own_account', + 'company_id': self.company_data['company'].id, + 'date': '2021-10-11', + 'analytic_distribution': {self.analytic_account_1.id: 100}, + }), Command.create({ + 'name': 'PB 160 + 2*15%', # Taxes are included + 'employee_id': self.expense_employee.id, + 'product_id': self.product_b.id, + 'payment_mode': 'own_account', + 'company_id': self.company_data['company'].id, + 'date': '2021-10-13', + 'analytic_distribution': {self.analytic_account_2.id: 100}, + })], + }) + expense_sheet_by_company = self.create_expense_report({ + 'name': 'Expense for Company', 'employee_id': self.expense_employee.id, - 'accounting_date': '2021-01-01', - 'expense_line_ids': [(0, 0, { - 'name': 'Car Travel Expenses', + 'expense_line_ids': [Command.create({ + 'name': 'PC 1000 + 15%', # Taxes are included 'employee_id': self.expense_employee.id, 'product_id': self.product_c.id, - 'total_amount': 350.00, + 'total_amount_currency': 1000.00, + 'date': '2021-10-11', + 'payment_mode': 'company_account', + 'company_id': self.company_data['company'].id, 'tax_ids': [Command.set(self.tax_purchase_a.ids)], - })] + }), Command.create({ + 'name': 'PB 160 + 2*15% 2', # Taxes are included + 'employee_id': self.expense_employee.id, + 'product_id': self.product_b.id, + 'payment_mode': 'company_account', + 'company_id': self.company_data['company'].id, + 'date': '2021-10-12', # This should be the date set as the accounting_date + })], }) + expense_sheets = expense_sheet_by_employee | expense_sheet_by_company - expense_sheet.action_submit_sheet() - self.assertEqual(expense_sheet.state, 'submit', 'sheet should be submitted') - - expense_sheet.action_approve_expense_sheets() - self.assertEqual(expense_sheet.state, 'approve', 'sheet should be aproved') - - expense_sheet.action_sheet_move_create() - self.assertEqual(expense_sheet.state, 'post', 'sheet should be posted') - - move = expense_sheet.account_move_ids - - self.assertRecordValues(move.line_ids, [ - {'debit': 304.35, 'credit': 0.0, 'reconciled': False, }, - {'debit': 45.65, 'credit': 0.0, 'reconciled': False}, - {'debit': 0.0, 'credit': 350, 'reconciled': False}, + # Checking expense sheets values at creation + self.assertRecordValues(expense_sheets, [ + {'total_amount': 1760.00, 'untaxed_amount': 1514.38, 'total_tax_amount': 245.62, 'state': 'draft', 'accounting_date': date(2021, 10, 10)}, + {'total_amount': 1160.00, 'untaxed_amount': 992.65, 'total_tax_amount': 167.35, 'state': 'draft', 'accounting_date': False}, + ]) + self.assertRecordValues(expense_sheets.expense_line_ids, [ + {'total_amount_currency': 1600.00, 'untaxed_amount_currency': 1391.30, 'price_unit': 800.00, 'tax_amount': 208.70, 'state': 'reported'}, + {'total_amount_currency': 160.00, 'untaxed_amount_currency': 123.08, 'price_unit': 160.00, 'tax_amount': 36.92, 'state': 'reported'}, + {'total_amount_currency': 1000.00, 'untaxed_amount_currency': 869.57, 'price_unit': 1000.00, 'tax_amount': 130.43, 'state': 'reported'}, + {'total_amount_currency': 160.00, 'untaxed_amount_currency': 123.08, 'price_unit': 160.00, 'tax_amount': 36.92, 'state': 'reported'}, ]) - expense_sheet.action_reset_expense_sheets() - self.assertEqual(expense_sheet.state, 'draft', 'sheet should be reverted to draft') + # Submitting properly change states + expense_sheets.action_submit_sheet() + self.assertRecordValues(expense_sheets, [ + {'state': 'submit'}, + {'state': 'submit'}, + ]) + self.assertRecordValues(expense_sheets.expense_line_ids, [ + {'state': 'submitted'}, + {'state': 'submitted'}, + {'state': 'submitted'}, + {'state': 'submitted'}, + ]) - reverse_move = self.env['account.move'].search([('reversed_entry_id', '=', move.id)]) - self.assertFalse(expense_sheet.account_move_ids) - self.assertEqual(move.payment_state, 'reversed', 'vendor bill should be reversed') - self.assertTrue(350 == expense_sheet.total_amount == move.amount_total == reverse_move.amount_total, 'taxes properly included in price') + # Approving properly change states + expense_sheets.action_approve_expense_sheets() + self.assertRecordValues(expense_sheets, [ + {'state': 'approve'}, + {'state': 'approve'}, + ]) + self.assertRecordValues(expense_sheets.expense_line_ids, [ + {'state': 'approved'}, + {'state': 'approved'}, + {'state': 'approved'}, + {'state': 'approved'}, + ]) - expense_sheet.action_submit_sheet() - expense_sheet.action_approve_expense_sheets() - expense_sheet.action_sheet_move_create() + # Generate a payment for 'company_account' (and its move(s)) and a vendor bill for 'own_account' + expense_sheets.action_sheet_move_create() + self.assertRecordValues(expense_sheets, [ + {'state': 'post', 'payment_state': 'not_paid', 'accounting_date': date(2021, 10, 10)}, + {'state': 'done', 'payment_state': 'paid', 'accounting_date': date(2021, 10, 12)}, # Set to paid as move is posted directly + ]) + self.assertRecordValues(expense_sheets.expense_line_ids, [ + {'payment_mode': 'own_account', 'state': 'approved'}, # vv + {'payment_mode': 'own_account', 'state': 'approved'}, # As the payment is not done yet those are still in "approved" + {'payment_mode': 'company_account', 'state': 'done'}, + {'payment_mode': 'company_account', 'state': 'done'}, + ]) + # One payment for the whole sheet if 'own_account' + expected_partner_id = self.expense_user_employee.partner_id.id + self.assertRecordValues(expense_sheet_by_employee.account_move_ids, [{ + 'amount_total': 1760.00, + 'ref': 'Expense for John Smith', + 'date': date(2021, 10, 10), + 'invoice_date_due': date(2021, 11, 9), # The due date is the one set for the partner + 'partner_id': expected_partner_id + }, + ]) + # One payment per expense if 'company_account' + self.assertRecordValues(expense_sheet_by_company.account_move_ids, [ + {'amount_total': 1000.00, 'ref': 'PC 1000 + 15%', 'date': date(2021, 10, 12), 'partner_id': False}, + {'amount_total': 160.00, 'ref': 'PB 160 + 2*15% 2', 'date': date(2021, 10, 12), 'partner_id': False}, + ]) + tax_account_id = self.company_data['default_account_tax_purchase'].id + default_account_payable_id = self.company_data['default_account_payable'].id + product_b_account_id = self.product_b.property_account_expense_id.id + product_c_account_id = self.product_c.property_account_expense_id.id + company_payment_account_id = self.company_data['company'].account_journal_payment_credit_account_id.id + # One payment per expense + self.assertRecordValues(expense_sheets.account_move_ids.line_ids, [ + # own_account expense sheet move + {'balance': 123.08, 'account_id': product_b_account_id, 'name': 'expense_employee: PB 160 + 2*15%', 'date': date(2021, 10, 10)}, + {'balance': 1391.30, 'account_id': self.expense_account.id, 'name': 'expense_employee: PA 2*800 + 15%', 'date': date(2021, 10, 10)}, + {'balance': 18.46, 'account_id': tax_account_id, 'name': '15%', 'date': date(2021, 10, 10)}, + {'balance': 18.46, 'account_id': tax_account_id, 'name': '15% (Copy)', 'date': date(2021, 10, 10)}, + {'balance': 208.70, 'account_id': tax_account_id, 'name': '15%', 'date': date(2021, 10, 10)}, + {'balance': -1760.00, 'account_id': default_account_payable_id, 'name': False, 'date': date(2021, 10, 10)}, - payment_1 = get_payment(expense_sheet, 100.0) + # company_account expense 1 move + {'balance': 869.57, 'account_id': product_c_account_id, 'name': 'expense_employee: PC 1000 + 15%', 'date': date(2021, 10, 12)}, + {'balance': 130.43, 'account_id': tax_account_id, 'name': '15%', 'date': date(2021, 10, 12)}, + {'balance': -1000.00, 'account_id': company_payment_account_id, 'name': 'expense_employee: PC 1000 + 15%', 'date': date(2021, 10, 12)}, + + # company_account expense 2 move + {'balance': 123.08, 'account_id': product_b_account_id, 'name': 'expense_employee: PB 160 + 2*15% 2', 'date': date(2021, 10, 12)}, + {'balance': 18.46, 'account_id': tax_account_id, 'name': '15%', 'date': date(2021, 10, 12)}, + {'balance': 18.46, 'account_id': tax_account_id, 'name': '15% (Copy)', 'date': date(2021, 10, 12)}, + {'balance': -160.00, 'account_id': company_payment_account_id, 'name': 'expense_employee: PB 160 + 2*15% 2', 'date': date(2021, 10, 12)}, + ]) + + # Own_account partial payment + payment_1 = self.get_new_payment(expense_sheet_by_employee, 1700.0) liquidity_lines1 = payment_1._seek_for_lines()[0] + self.assertRecordValues(expense_sheet_by_employee, [{'payment_state': 'partial', 'state': 'done'}]) - self.assertEqual(expense_sheet.payment_state, 'partial', 'payment_state should be partial') - - payment_2 = get_payment(expense_sheet, 250.0) + # own_account remaining payment + payment_2 = self.get_new_payment(expense_sheet_by_employee, 60.0) liquidity_lines2 = payment_2._seek_for_lines()[0] + in_payment_state = expense_sheet_by_employee.account_move_ids._get_invoice_in_payment_state() + self.assertRecordValues(expense_sheet_by_employee, [{'payment_state': in_payment_state, 'state': 'done'}]) + self.assertRecordValues(expense_sheet_by_employee.expense_line_ids, [{'state': 'done'}] * 2) - in_payment_state = expense_sheet.account_move_ids._get_invoice_in_payment_state() - self.assertEqual(expense_sheet.payment_state, in_payment_state, 'payment_state should be ' + in_payment_state) - self.assertEqual(expense_sheet.state, 'done', 'sheet should be marked as done') - + # Reconciling own_account statement_line = self.env['account.bank.statement.line'].create({ 'journal_id': self.company_data['default_journal_bank'].id, 'payment_ref': 'pay_ref', - 'amount': -350.0, + 'amount': -1760.0, 'partner_id': self.expense_employee.work_contact_id.id, }) # Reconcile without the bank reconciliation widget since the widget is in enterprise. - _st_liquidity_lines, st_suspense_lines, _st_other_lines = statement_line\ - .with_context(skip_account_move_synchronization=True)\ - ._seek_for_lines() + _trash, st_suspense_lines, _trash = statement_line.with_context(skip_account_move_synchronization=True)._seek_for_lines() st_suspense_lines.account_id = liquidity_lines1.account_id (st_suspense_lines + liquidity_lines1 + liquidity_lines2).reconcile() + self.assertRecordValues(expense_sheet_by_employee, [{'payment_state': 'paid', 'state': 'done'}]) - self.assertEqual(expense_sheet.payment_state, 'paid', 'payment_state should be paid') - self.assertEqual(expense_sheet.state, 'done', 'sheet should be marked as done') + # Trying to delete analytic accounts should be forbidden if linked to an expense + with self.assertRaises(UserError): + (self.analytic_account_1 | self.analytic_account_2).unlink() - payment_1.action_draft() - payment_2.action_draft() + # Unlinking moves + (payment_1 | payment_2).action_draft() + self.assertRecordValues(expense_sheet_by_employee, [{'payment_state': 'not_paid', 'state': 'post'}]) + expense_sheet_by_employee.account_move_ids.button_draft() + expense_sheet_by_employee.account_move_ids.unlink() - self.assertEqual(expense_sheet.state, 'post', 'sheet should be reverted to posted') - self.assertEqual(expense_sheet.payment_state, 'not_paid', 'payment_state should be not_paid') + with self.assertRaises(UserError, msg="For company-paid expenses report, deleting payments is an all-or-nothing situation"): + expense_sheet_by_company.account_move_ids[:-1].payment_id.unlink() + expense_sheet_by_company.account_move_ids.payment_id.unlink() - def test_expense_sheet_paid_company(self): - ''' Test expense sheet paid by company flow ''' + self.assertRecordValues(expense_sheets.sorted('payment_mode'), [ + {'payment_mode': 'company_account', 'state': 'approve', 'payment_state': 'not_paid', 'account_move_ids': []}, + {'payment_mode': 'own_account', 'state': 'approve', 'payment_state': 'not_paid', 'account_move_ids': []}, + ]) - expense_sheet = self.env['hr.expense.sheet'].create({ - 'name': 'Expense for John Smith', - 'employee_id': self.expense_employee.id, - 'accounting_date': '2021-01-01', - 'payment_method_line_id': self.outbound_payment_method_line.id, - 'expense_line_ids': [(0, 0, { - 'name': 'Car Travel Expenses', - 'employee_id': self.expense_employee.id, - 'product_id': self.product_c.id, - 'total_amount': 350.00, - 'payment_mode': 'company_account', - })] - }) + expense_sheet_by_employee.action_reset_expense_sheets() + self.assertRecordValues(expense_sheet_by_employee, [{'state': 'draft', 'payment_state': 'not_paid', 'account_move_ids': []}]) + expense_sheet_by_employee.expense_line_ids.unlink() + # Only possible if no expense linked to the account + self.analytic_account_1.unlink() - expense_sheet.action_submit_sheet() - expense_sheet.action_approve_expense_sheets() - expense_sheet.action_sheet_move_create() + def test_expense_split_flow(self): + """ Check Split Expense flow. """ + expense = self.create_expense({'analytic_distribution': {self.analytic_account_1.id: 100}}) - move = expense_sheet.account_move_ids + wizard = self.env['hr.expense.split.wizard'].browse(expense.action_split_wizard()['res_id']) - self.assertEqual(expense_sheet.state, 'done', 'sheet should be marked as done') - self.assertTrue(350 == expense_sheet.total_amount == move.amount_total == move.payment_id.amount) - - self.assertEqual(expense_sheet.payment_state, 'paid', 'payment_state should be paid') - - def test_expense_values(self): - """ Checking accounting move entries and analytic entries when submitting expense """ - # The expense employee is able to a create an expense sheet. - # The total should be 1500.0 because: - # - first line: 1000.0 (unit amount), 130.43 (tax). But taxes are included in total thus - 1000 - # - second line: (1500.0 (unit amount), 195.652 (tax)) - 65.22 (tax in company currency). total 1500.0 * 1/3 (rate) = 500 - - expense_sheet = self.env['hr.expense.sheet'].create({ - 'name': 'First Expense for employee', - 'employee_id': self.expense_employee.id, - 'journal_id': self.company_data['default_journal_purchase'].id, - 'expense_line_ids': [ - (0, 0, { - # Expense without foreign currency. - 'name': 'expense_company_currency', - 'date': '2016-01-01', - 'product_id': self.product_a.id, - 'quantity': 1.25, - 'tax_ids': [(6, 0, self.company_data['default_tax_purchase'].ids)], - 'analytic_distribution': {self.analytic_account_1.id: 100}, - 'employee_id': self.expense_employee.id, - }), - (0, 0, { - # Expense with foreign currency (rate 1:3). - 'name': 'expense_foreign_currency', - 'date': '2016-01-01', - 'product_id': self.product_c.id, # product with no cost, else not possible to enter amount in different currency - 'total_amount': 1500.0, - 'tax_ids': [(6, 0, self.company_data['default_tax_purchase'].ids)], - 'analytic_distribution': {self.analytic_account_2.id: 100}, - 'currency_id': self.currency_data['currency'].id, - 'employee_id': self.expense_employee.id, - }), - ], - }) - - self.assertRecordValues(expense_sheet, [{'state': 'draft', 'total_amount': 1500.0}]) - - expense_sheet.action_submit_sheet() - expense_sheet.action_approve_expense_sheets() - expense_sheet.action_sheet_move_create() - - # Check expense sheet journal entry values. - self.assertRecordValues(expense_sheet.account_move_ids.line_ids.sorted('balance'), [ - # Receivable line (company currency): + # Check default hr.expense.split values + self.assertRecordValues(wizard.expense_split_line_ids, [ { - 'debit': 0.0, - 'credit': 1500.0, - 'amount_currency': -1500.0, - 'account_id': self.company_data['default_account_payable'].id, - 'product_id': False, - 'currency_id': self.company_data['currency'].id, - 'tax_line_id': False, + 'name': expense.name, + 'wizard_id': wizard.id, + 'expense_id': expense.id, + 'product_id': expense.product_id.id, + 'tax_ids': expense.tax_ids.ids, + 'total_amount_currency': expense.total_amount_currency / 2, + 'tax_amount_currency': 65.22, + 'employee_id': expense.employee_id.id, + 'company_id': expense.company_id.id, + 'currency_id': expense.currency_id.id, + 'analytic_distribution': expense.analytic_distribution, + }] * 2) + self.assertRecordValues(wizard, [{'split_possible': True, 'total_amount_currency': expense.total_amount_currency}]) + + # Grant Analytic Accounting rights, to be able to modify analytic_distribution from the wizard + self.env.user.groups_id += self.env.ref('analytic.group_analytic_accounting') + + with Form(wizard) as form: + form.expense_split_line_ids.remove(index=0) + self.assertEqual(form.split_possible, False) + + # Check removing tax_ids and analytic_distribution + with form.expense_split_line_ids.edit(0) as line: + line.total_amount_currency = 200.00 + line.tax_ids.clear() + line.analytic_distribution = {} + self.assertEqual(line.total_amount_currency, 200.00) + self.assertEqual(line.tax_amount_currency, 0.00) + self.assertEqual(form.split_possible, False) + + # This line should have the same tax_ids and analytic_distribution as original expense + with form.expense_split_line_ids.new() as line: + line.total_amount_currency = 300.00 + self.assertEqual(line.total_amount_currency, 300.00) + self.assertEqual(line.tax_amount_currency, 39.13) + self.assertDictEqual(line.analytic_distribution, expense.analytic_distribution) + self.assertEqual(form.split_possible, False) + self.assertEqual(form.total_amount_currency, 500.00) + + # Check adding tax_ids and setting analytic_distribution + with form.expense_split_line_ids.new() as line: + line.total_amount_currency = 500.00 + line.tax_ids.add(self.tax_purchase_b) + line.analytic_distribution = {self.analytic_account_2.id: 100} + self.assertEqual(line.total_amount_currency, 500.00) + self.assertEqual(line.tax_amount_currency, 115.38) + + # Check wizard values + self.assertRecordValues(wizard, [ + {'total_amount_currency': 1000.00, 'total_amount_currency_original': 1000.00, 'tax_amount_currency': 154.51, 'split_possible': True} + ]) + + wizard.action_split_expense() + # Check that split resulted into expenses with correct values + expenses_after_split = self.env['hr.expense'].search([('name', '=', expense.name)]) + self.assertRecordValues(expenses_after_split.sorted('total_amount_currency'), [ + { + 'name': expense.name, + 'employee_id': expense.employee_id.id, + 'product_id': expense.product_id.id, + 'total_amount_currency': 200.00, + 'tax_ids': [], + 'tax_amount_currency': 0.00, + 'untaxed_amount_currency': 200.00, 'analytic_distribution': False, - }, - # Tax line (foreign currency): - { - 'debit': 65.22, - 'credit': 0.0, - 'amount_currency': 65.22, - 'account_id': self.company_data['default_account_tax_purchase'].id, - 'product_id': False, - 'currency_id': self.company_data['currency'].id, - 'tax_line_id': self.company_data['default_tax_purchase'].id, - 'analytic_distribution': False, - }, - # Tax line (company currency): - { - 'debit': 130.43, - 'credit': 0.0, - 'amount_currency': 130.43, - 'account_id': self.company_data['default_account_tax_purchase'].id, - 'product_id': False, - 'currency_id': self.company_data['currency'].id, - 'tax_line_id': self.company_data['default_tax_purchase'].id, - 'analytic_distribution': False, - }, - # Product line (foreign currency): - { - 'debit': 434.78, # 1500 * 1:3 (rate) / 1.15 (incl. tax) - 'credit': 0.0, - 'amount_currency': 434.78, # untaxed amount - 'account_id': self.product_c.property_account_expense_id.id, - 'product_id': self.product_c.id, - 'currency_id': self.company_data['currency'].id, - 'tax_line_id': False, - 'analytic_distribution': {str(self.analytic_account_2.id): 100}, - }, - # Product line (company currency): - { - 'debit': 869.57, # 1000 / 1.15 (incl. tax) - 'credit': 0.0, - 'amount_currency': 869.57, - 'account_id': self.company_data['default_account_expense'].id, - 'product_id': self.product_a.id, - 'currency_id': self.company_data['currency'].id, - 'tax_line_id': False, + }, { + 'name': expense.name, + 'employee_id': expense.employee_id.id, + 'product_id': expense.product_id.id, + 'total_amount_currency': 300.00, + 'tax_ids': [self.tax_purchase_a.id], + 'tax_amount_currency': 39.13, + 'untaxed_amount_currency': 260.87, 'analytic_distribution': {str(self.analytic_account_1.id): 100}, - }, - ]) - - # Check expense analytic lines. - self.assertRecordValues(expense_sheet.account_move_ids.line_ids.analytic_line_ids.sorted('amount'), [ - { - 'amount': -869.57, - 'date': fields.Date.from_string('2016-01-01'), - 'account_id': self.analytic_account_1.id, - 'currency_id': self.company_data['currency'].id, - }, - { - 'amount': -434.78, - 'date': fields.Date.from_string('2016-01-01'), - 'account_id': self.analytic_account_2.id, - 'currency_id': self.company_data['currency'].id, - }, - ]) - - def test_expense_company_account(self): - """ Create an expense with payment mode 'Company' and post it (it should not fail) """ - with Form(self.env['hr.expense']) as expense_form: - expense_form.name = 'Company expense' - expense_form.date = '2022-11-17' - expense_form.total_amount = 1000.0 - expense_form.payment_mode = 'company_account' - expense_form.employee_id = self.expense_employee - expense_form.product_id = self.product_a - expense = expense_form.save() - - with Form(self.env['hr.expense.sheet']) as expense_sheet_form: - # Use same values that will be used by action_submit_expenses - expense_sheet_form.employee_id = expense.employee_id - expense_sheet_form.name = expense.name - expense_sheet_form.expense_line_ids.add(expense) - expense_sheet_form.payment_method_line_id = self.outbound_payment_method_line - expense_sheet = expense_sheet_form.save() - - expense_sheet.action_submit_sheet() - expense_sheet.action_approve_expense_sheets() - expense_sheet.action_sheet_move_create() - - def test_account_entry_multi_currency(self): - """ Checking accounting move entries and analytic entries when submitting expense. With - multi-currency. And taxes. """ - expense = self.env['hr.expense.sheet'].create({ - 'name': 'Expense for Dick Tracy', - 'employee_id': self.expense_employee.id, - }) - tax_account = self.env['account.account'].search([('account_type', '=', 'liability_current')], limit=1) - tax = self.env['account.tax'].create({ - 'name': 'Tax Expense 10%', - 'amount': 10, - 'amount_type': 'percent', - 'type_tax_use': 'purchase', - 'price_include': True, - 'repartition_line_ids': [ - Command.create({'document_type': 'invoice', 'repartition_type': 'base'}), - Command.create({'document_type': 'invoice', 'repartition_type': 'tax', 'account_id': tax_account.id}), - Command.create({'document_type': 'refund', 'repartition_type': 'base'}), - Command.create({'document_type': 'refund', 'repartition_type': 'tax', 'account_id': tax_account.id}), - ] - }) - self.env['hr.expense'].create({ - 'name': 'Choucroute Saucisse', - 'employee_id': self.expense_employee.id, - 'product_id': self.product_c.id, - 'total_amount': 700.0, - 'tax_ids': [(6, 0, tax.ids)], - 'sheet_id': expense.id, - 'analytic_distribution': {self.analytic_account_1.id: 100}, - 'currency_id': self.currency_data['currency'].id, # rate is 1:2 - }) - - # State should default to draft - self.assertEqual(expense.state, 'draft', 'Expense should be created in Draft state') - # Submitted to Manager - expense.action_submit_sheet() - self.assertEqual(expense.state, 'submit', 'Expense is not in Reported state') - # Approve - expense.action_approve_expense_sheets() - self.assertEqual(expense.state, 'approve', 'Expense is not in Approved state') - # Create Expense Entries - expense.action_sheet_move_create() - self.assertEqual(expense.state, 'post', 'Expense is not in Waiting Payment state') - # Should get this result [(0.0, 350.0, -700.0), (318.18, 0.0, 636.36), (31.82, 0.0, 63.64)] - analytic_line = expense.account_move_ids.line_ids.analytic_line_ids - self.assertEqual(len(analytic_line), 1) - - # Expenses paid by the employee are always translated in company currency - self.assertInvoiceValues(expense.account_move_ids, [ - { - 'balance': 318.18, # 700 * 1:2 (rate) / 1.1 (incl. tax) - 'amount_currency': 318.18, - 'product_id': self.product_c.id, - 'price_unit': 350.0, - 'price_subtotal': 318.18, - 'price_total': 350.0, - 'analytic_line_ids': analytic_line.ids, }, { - 'balance': 31.82, - 'amount_currency': 31.82, - 'product_id': False, - 'price_unit': 0.0, - 'price_subtotal': 0.0, - 'price_total': 0.0, - 'analytic_line_ids': [], - }, { - 'balance': -350.0, - 'amount_currency': -350.0, - 'product_id': False, - 'price_unit': 0.0, - 'price_subtotal': 0.0, - 'price_total': 0.0, - 'analytic_line_ids': [], - }, - ], { - 'amount_total': 350.0, - }) - - def test_account_entry_multi_currency_company_account(self): - """ Checking accounting payment entry when payment_mode is 'Company'. With multi-currency.""" - expense = self.env['hr.expense'].create({ - 'name': 'Company expense', - 'date': '2022-11-17', - 'total_amount': 1000.0, - 'payment_mode': 'company_account', - 'employee_id': self.expense_employee.id, - 'product_id': self.product_c.id, - 'currency_id': self.currency_data['currency'].id, # rate is 1:2 - }) - - foreign_bank_journal = self.company_data['default_journal_bank'].copy() - foreign_bank_journal_account = foreign_bank_journal.default_account_id.copy() - foreign_bank_journal_account.currency_id = self.currency_data['currency'].id - foreign_bank_journal.write({ - 'currency_id': self.currency_data['currency'].id, - 'default_account_id': foreign_bank_journal_account.id - }) - expense_sheet = self.env['hr.expense.sheet'].create({ - 'name': "test_account_entry_multi_currency_own_account", - 'employee_id': self.expense_employee.id, - 'accounting_date': '2020-01-01', - 'payment_method_line_id': foreign_bank_journal.outbound_payment_method_line_ids[0].id, - 'expense_line_ids': [Command.set(expense.ids)], - }) - - expense_sheet.action_submit_sheet() - expense_sheet.action_approve_expense_sheets() - expense_sheet.action_sheet_move_create() - self.assertRecordValues(expense_sheet.account_move_ids.payment_id, [{ - 'currency_id': self.currency_data['currency'].id, - }]) - self.assertRecordValues(expense_sheet.account_move_ids.line_ids, [ - {'currency_id': self.currency_data['currency'].id}, - {'currency_id': self.currency_data['currency'].id}, - {'currency_id': self.currency_data['currency'].id}, - {'currency_id': self.currency_data['currency'].id}, + 'name': expense.name, + 'employee_id': expense.employee_id.id, + 'product_id': expense.product_id.id, + 'total_amount_currency': 500.00, + 'tax_ids': [self.tax_purchase_a.id, self.tax_purchase_b.id], + 'tax_amount_currency': 115.38, + 'untaxed_amount_currency': 384.62, + 'analytic_distribution': {str(self.analytic_account_2.id): 100}, + } ]) - def test_account_entry_multi_currency_own_account(self): - """ Checking accounting payment entry when payment_mode is 'Company'. With multi-currency.""" - expense = self.env['hr.expense'].create({ - 'name': 'Company expense', - 'date': '2022-11-17', - 'payment_mode': 'own_account', - 'employee_id': self.expense_employee.id, - 'product_id': self.product_a.id, - 'currency_id': self.currency_data['currency'].id, # rate is 1:2 - }) + ############################################# + # Test Multi-currency + ############################################# - foreign_sale_journal = self.company_data['default_journal_sale'].copy() - foreign_sale_journal.currency_id = self.currency_data['currency'].id - - expense_sheet = self.env['hr.expense.sheet'].create({ - 'name': "test_account_entry_multi_currency_own_account", - 'employee_id': self.expense_employee.id, - 'accounting_date': '2020-01-01', - 'journal_id': foreign_sale_journal.id, - 'expense_line_ids': [Command.set(expense.ids)], - }) - - expense_sheet.action_submit_sheet() - expense_sheet.action_approve_expense_sheets() - expense_sheet.action_sheet_move_create() - self.assertRecordValues(expense_sheet.account_move_ids, [{ - 'currency_id': expense_sheet.company_id.currency_id.id, - }]) - self.assertRecordValues(expense_sheet.account_move_ids.line_ids, [ - {'currency_id': expense_sheet.company_id.currency_id.id}, - {'currency_id': expense_sheet.company_id.currency_id.id}, - {'currency_id': expense_sheet.company_id.currency_id.id}, - ]) - - def test_multicurrencies_rounding_consistency(self): + def test_expense_multi_currencies(self): + """ + Checks that the currency rate is recomputed properly when the total in company currency is set to a new value + and that extreme rounding cases do not end up with non-consistend data + """ # pylint: disable=bad-whitespace - foreign_currency = self.env['res.currency'].create({ - 'name': 'Exposure', - 'symbol': ' ', - 'rounding': 0.01, - 'position': 'after', - 'currency_unit_label': 'Nothing', - 'currency_subunit_label': 'Smaller Nothing', + foreign_currency_1 = self.currency_data['currency'] + foreign_currency_2, foreign_currency_3 = self.env['res.currency'].create([{ + 'name': 'Ex1', + 'symbol': ' ', + 'rounding': 0.01, + 'position': 'after', + 'currency_unit_label': 'Nothing', + 'currency_subunit_label': 'Smaller Nothing', + }, { + 'name': 'Ex2', + 'symbol': ' ', + 'rounding': 0.01, + 'position': 'after', + 'currency_unit_label': 'Nothing 2', + 'currency_subunit_label': 'Smaller Nothing 2', + }, + ]) + self.env['res.currency.rate'].create({ + 'name': '2016-01-01', + 'rate': 1 / 1.52, + 'currency_id': foreign_currency_2.id, + 'company_id': self.company_data['company'].id, }) self.env['res.currency.rate'].create({ 'name': '2016-01-01', - 'rate': 1/0.148431, - 'currency_id': foreign_currency.id, + 'rate': 1 / 0.148431, + 'currency_id': foreign_currency_3.id, 'company_id': self.company_data['company'].id, }) foreign_sale_journal = self.company_data['default_journal_sale'].copy() - foreign_sale_journal.currency_id = foreign_currency.id - tax = self.env['account.tax'].create({ - 'name': 'Tax Expense 15%', - 'amount': 15, - 'amount_type': 'percent', - 'type_tax_use': 'purchase', - 'price_include': True, - }) - taxes = tax + tax.copy() - - expense_sheet_own_1_tax = self.env['hr.expense.sheet'].create({ - 'name': "own expense 1 tax", - 'employee_id': self.expense_employee.id, - 'accounting_date': '2020-01-01', + foreign_sale_journal.currency_id = foreign_currency_2.id + expense_sheet_currency_mix_1 = self.create_expense_report({ 'journal_id': foreign_sale_journal.id, 'expense_line_ids': [Command.create({ - 'name': 'Own expense', - 'date': '2022-11-16', - 'payment_mode': 'own_account', - 'total_amount': 100, - 'employee_id': self.expense_employee.id, - 'product_id': self.product_c.id, - 'currency_id': foreign_currency.id, # rate is 1:0.148431 - 'tax_ids': [Command.set(tax.ids)], + 'payment_mode': 'company_account', + 'employee_id': self.expense_employee.id, + 'product_id': self.product_c.id, + 'total_amount_currency': 1000.00, + 'date': self.frozen_today, + 'company_id': self.company_data['company'].id, + 'currency_id': foreign_currency_1.id, # rate is 1:2 + 'tax_ids': [Command.set(self.tax_purchase_a.ids)], })], }) - expense_sheet_own_2_tax = self.env['hr.expense.sheet'].create({ - 'name': "own expense 2 taxes", - 'employee_id': self.expense_employee.id, - 'accounting_date': '2020-01-01', + expense_sheet_currency_mix_2 = self.create_expense_report({ 'journal_id': foreign_sale_journal.id, 'expense_line_ids': [Command.create({ - 'name': 'Own expense', - 'date': '2022-11-17', - 'payment_mode': 'own_account', - 'total_amount': 100, - 'employee_id': self.expense_employee.id, - 'product_id': self.product_c.id, - 'currency_id': foreign_currency.id, # rate is 1:0.148431 - 'tax_ids': [Command.set(taxes.ids)], - })], - }) - expense_sheet_company_1_tax = self.env['hr.expense.sheet'].create({ - 'name': "company expense 1 taxes", - 'employee_id': self.expense_employee.id, - 'accounting_date': '2020-01-01', - 'journal_id': foreign_sale_journal.id, - 'expense_line_ids': [Command.create({ - 'name': 'Company expense', - 'date': '2022-11-18', 'payment_mode': 'company_account', - 'total_amount': 100, 'employee_id': self.expense_employee.id, 'product_id': self.product_c.id, - 'currency_id': foreign_currency.id, # rate is 1:0.148431 - 'tax_ids': [Command.set(tax.ids)], + 'total_amount_currency': 1000.00, + 'date': self.frozen_today, + 'company_id': self.company_data['company'].id, + 'currency_id': foreign_currency_2.id, # rate is 1:1.52 + 'tax_ids': [Command.set(self.tax_purchase_a.ids)], + }), Command.create({ + 'payment_mode': 'company_account', + 'employee_id': self.expense_employee.id, + 'product_id': self.product_c.id, + 'total_amount_currency': 1000.00, + 'date': self.frozen_today, + 'company_id': self.company_data['company'].id, + 'currency_id': foreign_currency_2.id, # rate is 1:1.52 + 'tax_ids': [Command.set((self.tax_purchase_a.id, self.tax_purchase_b.id))], })], }) - expense_sheet_company_2_tax = self.env['hr.expense.sheet'].create({ - 'name': "company expense 2 taxes", - 'employee_id': self.expense_employee.id, - 'accounting_date': '2020-01-01', + expense_sheet_currency_mix_3 = self.create_expense_report({ 'journal_id': foreign_sale_journal.id, 'expense_line_ids': [Command.create({ - 'name': 'Company expense', - 'date': '2022-11-19', 'payment_mode': 'company_account', - 'total_amount': 100, 'employee_id': self.expense_employee.id, 'product_id': self.product_c.id, - 'currency_id': foreign_currency.id, # rate is 1:0.148431 - 'tax_ids': [Command.set(taxes.ids)], + 'total_amount_currency': 1000.00, + 'date': self.frozen_today, + 'company_id': self.company_data['company'].id, + 'currency_id': foreign_currency_2.id, # rate is 1:1.52 + 'tax_ids': [Command.set(self.tax_purchase_a.ids)], + }), Command.create({ + 'payment_mode': 'company_account', + 'employee_id': self.expense_employee.id, + 'product_id': self.product_c.id, + 'total_amount_currency': 1000.00, + 'date': self.frozen_today, + 'company_id': self.company_data['company'].id, + 'currency_id': foreign_currency_1.id, # rate is 1:2 + 'tax_ids': [Command.set((self.tax_purchase_a.id, self.tax_purchase_b.id))], })], }) - sheets = expense_sheet_own_1_tax + expense_sheet_own_2_tax + expense_sheet_company_1_tax + expense_sheet_company_2_tax - self.assertRecordValues(sheets.expense_line_ids, [ - {'untaxed_amount': 86.96, 'total_amount': 100.00, 'total_amount_company': 14.84, 'amount_tax': 13.04, 'amount_tax_company': 1.94}, - {'untaxed_amount': 76.92, 'total_amount': 100.00, 'total_amount_company': 14.84, 'amount_tax': 23.08, 'amount_tax_company': 3.42}, - {'untaxed_amount': 86.96, 'total_amount': 100.00, 'total_amount_company': 14.84, 'amount_tax': 13.04, 'amount_tax_company': 1.94}, - {'untaxed_amount': 76.92, 'total_amount': 100.00, 'total_amount_company': 14.84, 'amount_tax': 23.08, 'amount_tax_company': 3.42}, + expense_sheet_currency_mix_4 = self.create_expense_report({ # This case handles a direct override in back-end of the rate + 'journal_id': foreign_sale_journal.id, + 'expense_line_ids': [Command.create({ + 'payment_mode': 'company_account', + 'employee_id': self.expense_employee.id, + 'product_id': self.product_c.id, + 'total_amount_currency': 1000.00, + 'total_amount': 3000.00, + 'date': self.frozen_today, + 'company_id': self.company_data['company'].id, + 'currency_id': foreign_currency_2.id, # default rate is 1:1.52, overriden to 3 + 'tax_ids': [Command.set(self.tax_purchase_a.ids)], + })], + }) + expenses_sheet_currencies_mix = expense_sheet_currency_mix_1 | expense_sheet_currency_mix_2 \ + | expense_sheet_currency_mix_3 | expense_sheet_currency_mix_4 + self.assertRecordValues(expenses_sheet_currencies_mix.expense_line_ids, [ + # Sheet 1, mono foreign currency + {'currency_rate': 0.50, 'total_amount_currency': 1000.00, 'total_amount': 500.00, 'currency_id': foreign_currency_1.id}, + # Sheet 2, multiple identical foreign currencies + {'currency_rate': 1.52, 'total_amount_currency': 1000.00, 'total_amount': 1520.00, 'currency_id': foreign_currency_2.id}, + {'currency_rate': 1.52, 'total_amount_currency': 1000.00, 'total_amount': 1520.00, 'currency_id': foreign_currency_2.id}, + # Sheet 3, multiple different foreign currencies + {'currency_rate': 1.52, 'total_amount_currency': 1000.00, 'total_amount': 1520.00, 'currency_id': foreign_currency_2.id}, + {'currency_rate': 0.50, 'total_amount_currency': 1000.00, 'total_amount': 500.00, 'currency_id': foreign_currency_1.id}, + # Sheet 4, mono foreign currencies already overriden + {'currency_rate': 3.00, 'total_amount_currency': 1000.00, 'total_amount': 3000.00, 'currency_id': foreign_currency_2.id}, ]) - sheets.action_submit_sheet() - sheets.action_approve_expense_sheets() - sheets.action_sheet_move_create() - self.assertRecordValues(expense_sheet_own_1_tax.account_move_ids.line_ids, [ - {'balance': 12.90, 'amount_currency': 12.90, 'currency_id': self.company_data['currency'].id}, - {'balance': 1.94, 'amount_currency': 1.94, 'currency_id': self.company_data['currency'].id}, - {'balance': -14.84, 'amount_currency': -14.84, 'currency_id': self.company_data['currency'].id}, + # Manually changing rate on the two first expenses after creation to check they recompute properly + # Back-end override + expense_sheet_currency_mix_1.expense_line_ids[0].write({'total_amount': 1000.00}) + + # Front-end override + expense = expense_sheet_currency_mix_2.expense_line_ids[0] + with Form(expense) as expense_form: + expense_form.total_amount = 2000.00 + + self.assertRecordValues(expenses_sheet_currencies_mix.expense_line_ids.sorted('id'), [ + {'currency_rate': 1.00, 'total_amount_currency': 1000.00, 'total_amount': 1000.00}, # Rate should change + {'currency_rate': 2.00, 'total_amount_currency': 1000.00, 'total_amount': 2000.00}, # Rate should change + {'currency_rate': 1.52, 'total_amount_currency': 1000.00, 'total_amount': 1520.00}, # Rate should NOT change + {'currency_rate': 1.52, 'total_amount_currency': 1000.00, 'total_amount': 1520.00}, # Rate should NOT change + {'currency_rate': 0.50, 'total_amount_currency': 1000.00, 'total_amount': 500.00}, # Rate should NOT change + {'currency_rate': 3.00, 'total_amount_currency': 1000.00, 'total_amount': 3000.00}, # Rate should not revert to the default one (1.52) ]) - self.assertRecordValues(expense_sheet_own_2_tax.account_move_ids.line_ids, [ - {'balance': 11.42, 'amount_currency': 11.42, 'currency_id': self.company_data['currency'].id}, - {'balance': 1.71, 'amount_currency': 1.71, 'currency_id': self.company_data['currency'].id}, - {'balance': 1.71, 'amount_currency': 1.71, 'currency_id': self.company_data['currency'].id}, # == 3.42 amount_tax_company - {'balance': -14.84, 'amount_currency': -14.84, 'currency_id': self.company_data['currency'].id}, + # Sheet and move creation should not touch the rates anymore + expenses_sheet_currencies_mix.action_submit_sheet() + expenses_sheet_currencies_mix.action_approve_expense_sheets() + expenses_sheet_currencies_mix.action_sheet_move_create() + self.assertRecordValues(expenses_sheet_currencies_mix.account_move_ids, [ + {'amount_total_in_currency_signed': 1000.00, 'amount_total_signed': 1000.00, 'currency_id': foreign_currency_1.id}, + {'amount_total_in_currency_signed': 1000.00, 'amount_total_signed': 2000.00, 'currency_id': foreign_currency_2.id}, + {'amount_total_in_currency_signed': 1000.00, 'amount_total_signed': 1520.00, 'currency_id': foreign_currency_2.id}, + {'amount_total_in_currency_signed': 1000.00, 'amount_total_signed': 1520.00, 'currency_id': foreign_currency_2.id}, + {'amount_total_in_currency_signed': 1000.00, 'amount_total_signed': 500.00, 'currency_id': foreign_currency_1.id}, + {'amount_total_in_currency_signed': 1000.00, 'amount_total_signed': 3000.00, 'currency_id': foreign_currency_2.id}, + ]) + self.assertRecordValues(expenses_sheet_currencies_mix.account_move_ids.payment_id, [ + {'amount': 1000.00, 'payment_type': 'outbound', 'currency_id': foreign_currency_1.id}, + {'amount': 1000.00, 'payment_type': 'outbound', 'currency_id': foreign_currency_2.id}, + {'amount': 1000.00, 'payment_type': 'outbound', 'currency_id': foreign_currency_2.id}, + {'amount': 1000.00, 'payment_type': 'outbound', 'currency_id': foreign_currency_2.id}, + {'amount': 1000.00, 'payment_type': 'outbound', 'currency_id': foreign_currency_1.id}, + {'amount': 1000.00, 'payment_type': 'outbound', 'currency_id': foreign_currency_2.id}, ]) - self.assertRecordValues(expense_sheet_company_1_tax.account_move_ids.line_ids, [ - {'balance': 12.90, 'amount_currency': 86.96, 'currency_id': foreign_currency.id}, - {'balance': 1.94, 'amount_currency': 13.04, 'currency_id': foreign_currency.id}, - {'balance': -14.84, 'amount_currency': -100.00, 'currency_id': foreign_currency.id}, + # Test that the roundings are consistent no matter by whom it is paid + expense_values = { + 'payment_mode': 'company_account', + 'total_amount_currency': 100.00, + 'employee_id': self.expense_employee.id, + 'product_id': self.product_c.id, + 'currency_id': foreign_currency_3.id, # rate is 1:0.148431 + 'tax_ids': [Command.set((self.tax_purchase_a.id, self.tax_purchase_b.id))], + } + expense_sheet_company_rounding = self.create_expense_report({'expense_line_ids': [Command.create(expense_values)]}) + del expense_values['payment_mode'] # Sets the default payment_mode (own_account) + expense_sheet_employee_rounding = self.create_expense_report({'expense_line_ids': [Command.create(expense_values)]}) + expense_sheets_rounding = expense_sheet_company_rounding | expense_sheet_employee_rounding + self.assertRecordValues(expense_sheets_rounding.expense_line_ids, [ + {'untaxed_amount_currency': 76.92, 'total_amount_currency': 100.00, 'total_amount': 14.84, 'tax_amount_currency': 23.08, 'tax_amount': 3.42}, + {'untaxed_amount_currency': 76.92, 'total_amount_currency': 100.00, 'total_amount': 14.84, 'tax_amount_currency': 23.08, 'tax_amount': 3.42}, ]) - self.assertRecordValues(expense_sheet_company_2_tax.account_move_ids.line_ids, [ - {'balance': 11.42, 'amount_currency': 76.92, 'currency_id': foreign_currency.id}, - {'balance': 1.71, 'amount_currency': 11.54, 'currency_id': foreign_currency.id}, # == 3.42 amount_tax_company & 23.08 amount_tax - {'balance': 1.71, 'amount_currency': 11.54, 'currency_id': foreign_currency.id}, # One cent more in currency due to rounding - {'balance': -14.84, 'amount_currency': -100.00, 'currency_id': foreign_currency.id}, + expense_sheets_rounding.action_submit_sheet() + expense_sheets_rounding.action_approve_expense_sheets() + expense_sheets_rounding.action_sheet_move_create() + + self.assertRecordValues(expense_sheets_rounding.account_move_ids.line_ids, [ + {'balance': 11.42, 'amount_currency': 76.92}, + {'balance': 1.71, 'amount_currency': 11.54}, # == 3.42 tax_amount & 23.08 tax_amount + {'balance': 1.71, 'amount_currency': 11.54}, + {'balance': -14.84, 'amount_currency': -100.00}, + + {'balance': 11.42, 'amount_currency': 11.42}, # Paid by employee so converted into company_currency + {'balance': 1.71, 'amount_currency': 1.71}, # == 3.42 tax_amount + {'balance': 1.71, 'amount_currency': 1.71}, + {'balance': -14.84, 'amount_currency': -14.84}, ]) + ############################################# + # Test Corner Cases + ############################################# + def test_expense_corner_case_changing_employee(self): + """ + Test changing an employee on the expense that is linked with the sheet. + - In case sheet has only one expense linked with it, than changing an employee on expense should trigger changing an employee + on the sheet itself. + - In case sheet has more than one expense linked with it, than changing an employee on one of the expenses, + should cause unlinking the expense from the sheet. + """ - def test_expenses_with_tax_and_lockdate(self): - ''' Test creating a journal entry for multiple expenses using taxes. A lock date is set in order to trigger - the recomputation of the taxes base amount. - ''' - self.env.company.tax_lock_date = '2020-02-01' + employee = self.env['hr.employee'].create({'name': 'Gabriel Iglesias'}) + expense_sheet_employee_1 = self.create_expense_report() # default employee is self.expense_employee + expense_employee_2 = self.create_expense({'employee_id': employee.id}) - expense = self.env['hr.expense.sheet'].create({ + expense_sheet_employee_1.expense_line_ids.employee_id = employee + self.assertEqual(expense_sheet_employee_1.employee_id, employee, 'Employee should have changed on the sheet') + + expense_sheet_employee_1.expense_line_ids |= expense_employee_2 + expense_employee_2.employee_id = self.expense_employee.id + self.assertEqual(expense_employee_2.sheet_id.id, False, 'Sheet should be unlinked from the expense') + + def test_expenses_corner_case_with_tax_and_lock_date(self): + """ Test that when creating an expense move in a locked period still works but its accounting date is the current day """ + self.env.company.tax_lock_date = '2022-01-01' + + expense_sheet = self.create_expense_report({ 'name': 'Expense for John Smith', - 'employee_id': self.expense_employee.id, - 'accounting_date': '2020-01-01' - }) - - for _i in range(2): - self.env['hr.expense'].create({ - 'name': 'Car Travel Expenses', + 'accounting_date': '2020-01-01', + 'expense_line_ids': [Command.create({ 'employee_id': self.expense_employee.id, - 'product_id': self.product_c.id, - 'total_amount': 350.00, - 'tax_ids': [(6, 0, [self.tax_purchase_a.id])], - 'sheet_id': expense.id, - 'analytic_distribution': {str(self.analytic_account_1.id): 100}, - }) - - expense.action_submit_sheet() - expense.action_approve_expense_sheets() - - # Assert not "Cannot create unbalanced journal entry" error. - expense.action_sheet_move_create() - - def test_reconcile_payment(self): - tax = self.env['account.tax'].create({ - 'name': 'tax abc', - 'type_tax_use': 'purchase', - 'amount_type': 'percent', - 'amount': 15, - 'price_include': False, - 'include_base_amount': False, - 'tax_exigibility': 'on_payment' - }) - company = self.env.company.id - tax.cash_basis_transition_account_id = self.env['account.account'].create({ - 'name': "test", - 'code': 999991, - 'reconcile': True, - 'account_type': 'asset_current', - 'company_id': company, - }).id - - sheet = self.env['hr.expense.sheet'].create({ - 'company_id': company, - 'employee_id': self.expense_employee.id, - 'name': 'test sheet', - 'expense_line_ids': [ - (0, 0, { - 'name': 'expense_1', - 'date': '2016-01-01', - 'product_id': self.product_c.id, - 'total_amount': 10.0, - 'employee_id': self.expense_employee.id, - 'tax_ids': tax - }), - (0, 0, { - 'name': 'expense_2', - 'date': '2016-01-01', - 'product_id': self.product_c.id, - 'total_amount': 1.0, - 'employee_id': self.expense_employee.id, - 'tax_ids': tax - }), - ], + 'product_id': self.product_a.id, + 'price_unit': 1000.00, + 'date': '2020-01-02', + })], }) + expense_sheet.action_submit_sheet() + with freeze_time(self.frozen_today): + expense_sheet.action_approve_expense_sheets() + expense_sheet.action_sheet_move_create() + self.assertEqual(expense_sheet.accounting_date, self.frozen_today) - #actions - sheet.action_submit_sheet() - sheet.action_approve_expense_sheets() - sheet.action_sheet_move_create() - action_data = sheet.action_register_payment() - wizard = Form(self.env['account.payment.register'].with_context(action_data['context'])).save() - action = wizard.action_create_payments() - self.assertEqual(sheet.state, 'done', 'all account.move.line linked to expenses must be reconciled after payment') - - def test_expense_amount_total_signed_compute(self): - sheet = self.env['hr.expense.sheet'].create({ - 'company_id': self.env.company.id, - 'employee_id': self.expense_employee.id, - 'name': 'test sheet', - 'expense_line_ids': [ - (0, 0, { - 'name': 'expense_1', - 'date': '2016-01-01', - 'product_id': self.product_c.id, - 'total_amount': 10.0, - 'employee_id': self.expense_employee.id - }), - ], - }) - - #actions - sheet.action_submit_sheet() - sheet.action_approve_expense_sheets() - sheet.action_sheet_move_create() - action_data = sheet.action_register_payment() - wizard = Form(self.env['account.payment.register'].with_context(action_data['context'])).save() - action = wizard.action_create_payments() - - move = self.env['account.payment'].browse(action['res_id']).move_id - self.assertEqual(move.amount_total_signed, 10.0, 'The total amount of the payment move is not correct') - - def test_form_defaults_from_product(self): - """ - As soon as you set a product, the expense name, uom, taxes and account are set - according to the product. - """ + def test_corner_case_defaults_values_from_product(self): + """ As soon as you set a product, the expense name, uom, taxes and account are set according to the product. """ # Disable multi-uom self.env.ref('base.group_user').implied_ids -= self.env.ref('uom.group_uom') self.expense_user_employee.groups_id -= self.env.ref('uom.group_uom') @@ -722,762 +581,6 @@ class TestExpenses(TestExpenseCommon): self.assertEqual(expense.tax_ids, product.supplier_taxes_id) self.assertEqual(expense.account_id, product._get_product_accounts()['expense']) - def test_expense_account(self): - """ Checking accounting move entries for the accounts set on the expenses """ - - account_expense_1 = self.env['account.account'].create({ - 'code': '610010', - 'name': 'Expense Account 1' - }) - account_expense_2 = self.env['account.account'].create({ - 'code': '610020', - 'name': 'Expense Account 2' - }) - - expense_sheet = self.env['hr.expense.sheet'].create({ - 'name': 'First Expense for employee', - 'employee_id': self.expense_employee.id, - 'journal_id': self.company_data['default_journal_purchase'].id, - 'accounting_date': '2022-01-20', - 'expense_line_ids': [ - Command.create({ - # Expense on Expense Account 1 - 'name': 'expense_1', - 'date': '2022-01-05', - 'account_id': account_expense_1.id, - 'product_id': self.product_c.id, - 'total_amount': 115.0, - 'tax_ids': [Command.set(self.tax_purchase_a.ids)], - 'employee_id': self.expense_employee.id, - }), - Command.create({ - # Expense on Expense Account 2 - 'name': 'expense_2', - 'date': '2022-01-08', - 'account_id': account_expense_2.id, - 'product_id': self.product_c.id, - 'total_amount': 230.0, - 'tax_ids': [Command.set(self.tax_purchase_a.ids)], - 'employee_id': self.expense_employee.id, - }), - ], - }) - - self.assertRecordValues(expense_sheet, [{'state': 'draft', 'total_amount': 345.0}]) - - expense_sheet.action_submit_sheet() - expense_sheet.action_approve_expense_sheets() - expense_sheet.action_sheet_move_create() - - # Check expense sheet journal entry values. - self.assertRecordValues(expense_sheet.account_move_ids.line_ids.sorted('balance'), [ - # Receivable lines: - { - 'balance': -345.0, # 115 + 230 - 'account_id': self.company_data['default_account_payable'].id, - }, - # Tax lines: - { - 'balance': 15.0, - 'account_id': self.company_data['default_account_tax_purchase'].id, - }, - { - 'balance': 30.0, - 'account_id': self.company_data['default_account_tax_purchase'].id, - }, - # Expense line 1: - { - 'balance': 100.0, # 115 / 1.15 (tax incl.) - 'account_id': account_expense_1.id, - }, - # Expense line 2: - { - 'balance': 200.0, # 230 / 1.15 (tax incl.) - 'account_id': account_expense_2.id, - }, - ]) - - def test_employee_supplier(self): - """ Checking accounting move entries for the supplier set to the employee """ - - expense_sheet = self.env['hr.expense.sheet'].create({ - 'name': 'First Expense for employee', - 'employee_id': self.expense_employee.id, - 'journal_id': self.company_data['default_journal_purchase'].id, - 'accounting_date': '2022-01-20', - 'expense_line_ids': [ - Command.create({ - # Expense on Expense Account 1 - 'name': 'expense_1', - 'date': '2022-01-05', - 'product_id': self.product_c.id, - 'total_amount': 115.0, - 'employee_id': self.expense_employee.id, - }), - Command.create({ - # Expense on Expense Account 2 - 'name': 'expense_2', - 'date': '2022-01-08', - 'product_id': self.product_c.id, - 'total_amount': 230.0, - 'employee_id': self.expense_employee.id, - }), - ], - }) - - expense_sheet.action_submit_sheet() - expense_sheet.action_approve_expense_sheets() - expense_sheet.action_sheet_move_create() - - # Check whether employee is set as supplier on the receipt - self.assertRecordValues(expense_sheet.account_move_ids, [{ - 'partner_id': self.expense_user_employee.partner_id.id, - }]) - - def test_print_expense_check(self): - """ - Test the check content when printing a check - that comes from an expense - """ - sheet = self.env['hr.expense.sheet'].create({ - 'company_id': self.env.company.id, - 'employee_id': self.expense_employee.id, - 'name': 'test sheet', - 'expense_line_ids': [ - (0, 0, { - 'name': 'expense_1', - 'date': '2016-01-01', - 'product_id': self.product_c.id, - 'total_amount': 10.0, - 'employee_id': self.expense_employee.id, - }), - (0, 0, { - 'name': 'expense_2', - 'date': '2016-01-01', - 'product_id': self.product_c.id, - 'total_amount': 1.0, - 'employee_id': self.expense_employee.id, - }), - ], - }) - - #actions - sheet.action_submit_sheet() - sheet.action_approve_expense_sheets() - sheet.action_sheet_move_create() - action_data = sheet.action_register_payment() - payment_method_line = self.env.company.bank_journal_ids.outbound_payment_method_line_ids.filtered(lambda m: m.code == 'check_printing') - with Form(self.env[action_data['res_model']].with_context(action_data['context'])) as wiz_form: - wiz_form.payment_method_line_id = payment_method_line - wizard = wiz_form.save() - action = wizard.action_create_payments() - self.assertEqual(sheet.state, 'done', 'all account.move.line linked to expenses must be reconciled after payment') - - payment = self.env[action['res_model']].browse(action['res_id']) - pages = payment._check_get_pages() - stub_line = pages[0]['stub_lines'][:1] - self.assertTrue(stub_line) - move = self.env[action_data['context']['active_model']].browse(action_data['context']['active_ids']) - self.assertDictEqual(stub_line[0], { - 'due_date': payment.date.strftime("%m/%d/%Y"), - 'number': ' - '.join([move.name, move.ref] if move.ref else [move.name]), - 'amount_total': formatLang(self.env, move.amount_total, currency_obj=self.env.company.currency_id), - 'amount_residual': '-', - 'amount_paid': formatLang(self.env, payment.amount_total, currency_obj=self.env.company.currency_id), - 'currency': self.env.company.currency_id - }) - - def test_hr_expense_split(self): - """ - Check Split Expense flow. - """ - expense = self.env['hr.expense'].create({ - 'name': 'Expense To Test Split - Diego, libre dans sa tête', - 'employee_id': self.expense_employee.id, - 'product_id': self.product_zero_cost.id, - 'total_amount': 100.00, - 'tax_ids': [(6, 0, [self.tax_purchase_a.id])], - 'analytic_distribution': {self.analytic_account_1.id: 100}, - }) - - split_wizard = expense.action_split_wizard() - wizard = self.env['hr.expense.split.wizard'].browse(split_wizard['res_id']) - - # Check default hr.expense.split values - self.assertRecordValues(wizard.expense_split_line_ids, [ - { - 'name': expense.name, - 'wizard_id': wizard.id, - 'expense_id': expense.id, - 'product_id': expense.product_id.id, - 'tax_ids': expense.tax_ids.ids, - 'total_amount': expense.total_amount / 2, - 'amount_tax': 6.52, - 'employee_id': expense.employee_id.id, - 'company_id': expense.company_id.id, - 'currency_id': expense.currency_id.id, - 'analytic_distribution': expense.analytic_distribution, - } for i in range(0, 2)]) - - self.assertEqual(wizard.split_possible, True) - self.assertEqual(wizard.total_amount, expense.total_amount) - - # Grant Analytic Accounting rights, to be able to modify analytic_distribution from the wizard - self.env.user.groups_id += self.env.ref('analytic.group_analytic_accounting') - - with Form(wizard) as form: - form.expense_split_line_ids.remove(index=0) - self.assertEqual(form.split_possible, False) - - # Check removing tax_ids and analytic_distribution - with form.expense_split_line_ids.edit(0) as line: - line.total_amount = 20 - line.tax_ids.clear() - line.analytic_distribution = {} - self.assertEqual(line.total_amount, 20) - self.assertEqual(line.amount_tax, 0) - - self.assertEqual(form.split_possible, False) - - # This line should have the same tax_ids and analytic_distribution as original expense - with form.expense_split_line_ids.new() as line: - line.total_amount = 30 - self.assertEqual(line.total_amount, 30) - self.assertEqual(line.amount_tax, 3.91) - self.assertEqual(form.split_possible, False) - self.assertEqual(form.total_amount, 50) - - # Check adding tax_ids and setting analytic_distribution - with form.expense_split_line_ids.new() as line: - line.total_amount = 50 - line.tax_ids.add(self.tax_purchase_b) - line.analytic_distribution = {self.analytic_account_2.id: 100} - self.assertEqual(line.total_amount, 50) - self.assertAlmostEqual(line.amount_tax, 11.54) - - # Check wizard values - self.assertEqual(form.total_amount, 100) - self.assertEqual(form.total_amount_original, 100) - self.assertAlmostEqual(form.total_amount_taxes, 15.45) - self.assertEqual(form.split_possible, True) - - wizard.action_split_expense() - # Check that split resulted into expenses with correct values - expenses_after_split = self.env['hr.expense'].search( - [ - ('name', '=', expense.name) - ] - ) - self.assertRecordValues(expenses_after_split.sorted('total_amount'), [ - { - 'name': expense.name, - 'employee_id': expense.employee_id.id, - 'product_id': expense.product_id.id, - 'total_amount': 20.0, - 'tax_ids': [], - 'amount_tax': 0, - 'untaxed_amount': 20, - 'analytic_distribution': False, - }, - { - 'name': expense.name, - 'employee_id': expense.employee_id.id, - 'product_id': expense.product_id.id, - 'total_amount': 30, - 'tax_ids': [self.tax_purchase_a.id], - 'amount_tax': 3.91, - 'untaxed_amount': 26.09, - 'analytic_distribution': {str(self.analytic_account_1.id): 100}, - }, - { - 'name': expense.name, - 'employee_id': expense.employee_id.id, - 'product_id': expense.product_id.id, - 'total_amount': 50, - 'tax_ids': [self.tax_purchase_a.id, self.tax_purchase_b.id], - 'amount_tax': 11.54, - 'untaxed_amount': 38.46, - 'analytic_distribution': {str(self.analytic_account_2.id): 100}, - } - ]) - - def test_analytic_account_deleted(self): - """ Test that an analytic account cannot be deleted if it is used in an expense """ - - expense = self.env['hr.expense.sheet'].create({ - 'name': 'Expense for Dick Tracy', - 'employee_id': self.expense_employee.id, - }) - expense = self.env['hr.expense'].create({ - 'name': 'Choucroute Saucisse', - 'employee_id': self.expense_employee.id, - 'product_id': self.product_c.id, - 'total_amount': 700.00, - 'sheet_id': expense.id, - 'analytic_distribution': { - self.analytic_account_1.id: 50, - self.analytic_account_2.id: 50, - }, - }) - - with self.assertRaises(UserError): - (self.analytic_account_1 | self.analytic_account_2).unlink() - - expense.unlink() - self.analytic_account_1.unlink() - - def test_expense_sheet_due_date(self): - """ Test expense sheet bill due date """ - - self.expense_employee.user_partner_id.property_supplier_payment_term_id = self.env.ref('account.account_payment_term_30days') - with freeze_time('2021-01-01'): - expense_sheet = self.env['hr.expense.sheet'].create({ - 'name': 'Expense for John Smith', - 'employee_id': self.expense_employee.id, - 'expense_line_ids': [Command.create({ - 'name': 'Car Travel Expenses', - 'employee_id': self.expense_employee.id, - 'product_id': self.product_c.id, - 'total_amount': 350.00, - 'date': '2021-01-01', - })], - }) - expense_sheet.action_submit_sheet() - expense_sheet.action_approve_expense_sheets() - expense_sheet.action_sheet_move_create() - moves = expense_sheet.account_move_ids - expected_date = fields.Date.from_string('2021-01-31') - self.assertEqual(moves.mapped('invoice_date_due'), [expected_date], 'Bill due date should follow employee payment terms') - - def test_inverse_total_amount(self): - """ Test if the inverse method works correctly """ - - expense = self.env['hr.expense'].create({ - 'name': 'Choucroute Saucisse', - 'employee_id': self.expense_employee.id, - 'product_id': self.product_c.id, - 'total_amount': 60, - 'tax_ids': [self.tax_purchase_a.id, self.tax_purchase_b.id], - 'analytic_distribution': { - self.analytic_account_1.id: 50, - self.analytic_account_2.id: 50, - }, - }) - - expense.total_amount = 90 - - self.assertEqual(expense.unit_amount, 90, 'Unit amount should be the same as total amount was written to') - - def test_expense_from_attachments(self): - # avoid passing through extraction when installed - if 'hr.expense.extract.words' in self.env: - self.env.company.expense_extract_show_ocr_option_selection = 'no_send' - self.env.user.employee_id = self.expense_employee.id - attachment = self.env['ir.attachment'].create({ - 'datas': b"R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=", - 'name': 'file.png', - 'res_model': 'hr.expense', - }) - product = self.env['product.product'].search([('can_be_expensed', '=', True)]) - # reproduce the same way we get the product by default - if product: - product = product.filtered(lambda p: p.default_code == "EXP_GEN") or product[0] - product.property_account_expense_id = self.company_data['default_account_payable'] - - self.env['hr.expense'].create_expense_from_attachments(attachment.id) - expense = self.env['hr.expense'].search([], order='id desc', limit=1) - self.assertEqual(expense.account_id, product.property_account_expense_id, "The expense account should be the default one of the product") - - def test_expense_sheet_payment_states(self): - sheet_company = self.env['hr.expense.sheet'].create({ - 'company_id': self.env.company.id, - 'employee_id': self.expense_employee.id, - 'name': 'test sheet', - 'expense_line_ids': [ - Command.create({ - 'name': 'expense_1', - 'date': '2016-01-01', - 'product_id': self.product_c.id, - 'total_amount': 10.0, - 'payment_mode': 'company_account', - 'employee_id': self.expense_employee.id - }), - Command.create({ - 'name': 'expense_2', - 'date': '2016-01-01', - 'product_id': self.product_c.id, - 'total_amount': 10.0, - 'payment_mode': 'company_account', - 'employee_id': self.expense_employee.id - }), - ], - }) - sheet_own = self.env['hr.expense.sheet'].create({ - 'company_id': self.env.company.id, - 'employee_id': self.expense_employee.id, - 'name': 'test sheet 2', - 'payment_mode': 'own_account', - 'expense_line_ids': [ - Command.create({ - 'name': 'expense_3', - 'date': '2016-01-01', - 'product_id': self.product_c.id, - 'total_amount': 4.0, - 'payment_mode': 'own_account', - 'employee_id': self.expense_employee.id - }), - Command.create({ - 'name': 'expense_4', - 'date': '2016-01-01', - 'product_id': self.product_c.id, - 'total_amount': 8.0, - 'payment_mode': 'own_account', - 'employee_id': self.expense_employee.id - }), - ], - }) - sheets = self.env['hr.expense.sheet'].browse((sheet_company.id, sheet_own.id)) - #actions - self.assertEqual('draft', sheet_own.state) - self.assertEqual('not_paid', sheet_own.payment_state) - self.assertEqual('draft', sheet_company.state) - self.assertEqual('not_paid', sheet_company.payment_state) - - sheets.action_submit_sheet() - self.assertEqual('submit', sheet_own.state) - self.assertEqual('not_paid', sheet_own.payment_state) - self.assertEqual('submit', sheet_company.state) - self.assertEqual('not_paid', sheet_company.payment_state) - - sheets.action_approve_expense_sheets() - self.assertEqual('approve', sheet_own.state) - self.assertEqual('not_paid', sheet_own.payment_state) - self.assertEqual('approve', sheet_company.state) - self.assertEqual('not_paid', sheet_company.payment_state) - - sheets.action_sheet_move_create() - self.assertEqual('post', sheet_own.state) - self.assertEqual('not_paid', sheet_own.payment_state) - self.assertEqual('done', sheet_company.state) - self.assertEqual('paid', sheet_company.payment_state) - - own_payment = sheet_own.account_move_ids[0].action_register_payment() - own_wizard = Form(self.env['account.payment.register'].with_context(own_payment['context'])).save() - own_wizard.action_create_payments() - self.assertEqual('done', sheet_own.state) - self.assertIn(sheet_own.payment_state, {'in_payment', 'paid'}) - - def test_expense_sheet_payments_amount(self): - sheet_company = self.env['hr.expense.sheet'].create({ - 'company_id': self.env.company.id, - 'employee_id': self.expense_employee.id, - 'name': 'test sheet', - 'expense_line_ids': [ - Command.create({ - 'name': 'expense_1', - 'date': '2016-01-01', - 'product_id': self.product_c.id, - 'total_amount': 10.0, - 'payment_mode': 'company_account', - 'employee_id': self.expense_employee.id - }), - Command.create({ - 'name': 'expense_2', - 'date': '2016-01-01', - 'product_id': self.product_c.id, - 'total_amount': 10.0, - 'payment_mode': 'company_account', - 'employee_id': self.expense_employee.id - }), - ], - }) - sheet_own = self.env['hr.expense.sheet'].create({ - 'company_id': self.env.company.id, - 'employee_id': self.expense_employee.id, - 'name': 'test sheet 2', - 'payment_mode': 'own_account', - 'expense_line_ids': [ - Command.create({ - 'name': 'expense_3', - 'date': '2016-01-01', - 'product_id': self.product_c.id, - 'total_amount': 4.0, - 'payment_mode': 'own_account', - 'employee_id': self.expense_employee.id - }), - Command.create({ - 'name': 'expense_4', - 'date': '2016-01-01', - 'product_id': self.product_c.id, - 'total_amount': 8.0, - 'payment_mode': 'own_account', - 'employee_id': self.expense_employee.id - }), - ], - }) - sheets = self.env['hr.expense.sheet'].browse((sheet_company.id, sheet_own.id)) - sheets.action_submit_sheet() - sheets.action_approve_expense_sheets() - sheets.action_sheet_move_create() - - self.assertEqual(1, len(sheet_own.account_move_ids), "When an expense is paid by the employee, one move is created") - self.assertEqual(2, len(sheet_company.account_move_ids), "When an expense is paid by the company, one move is created per expense") - - def test_payment_unlinks(self): - sheet_company = self.env['hr.expense.sheet'].create({ - 'company_id': self.env.company.id, - 'employee_id': self.expense_employee.id, - 'name': 'test sheet', - 'expense_line_ids': [ - Command.create({ - 'name': 'expense_1', - 'date': '2016-01-01', - 'product_id': self.product_c.id, - 'total_amount': 10.0, - 'payment_mode': 'company_account', - 'employee_id': self.expense_employee.id - }), - Command.create({ - 'name': 'expense_2', - 'date': '2016-01-01', - 'product_id': self.product_c.id, - 'total_amount': 10.0, - 'payment_mode': 'company_account', - 'employee_id': self.expense_employee.id - }), - ], - }) - sheet_own = self.env['hr.expense.sheet'].create({ - 'company_id': self.env.company.id, - 'employee_id': self.expense_employee.id, - 'name': 'test sheet 2', - 'payment_mode': 'own_account', - 'expense_line_ids': [ - Command.create({ - 'name': 'expense_3', - 'date': '2016-01-01', - 'product_id': self.product_c.id, - 'total_amount': 4.0, - 'payment_mode': 'own_account', - 'employee_id': self.expense_employee.id - }), - Command.create({ - 'name': 'expense_4', - 'date': '2016-01-01', - 'product_id': self.product_c.id, - 'total_amount': 8.0, - 'payment_mode': 'own_account', - 'employee_id': self.expense_employee.id - }), - ], - }) - sheets = sheet_company | sheet_own - sheets.action_submit_sheet() - sheets.action_approve_expense_sheets() - sheets.action_sheet_move_create() - own_payment = sheet_own.account_move_ids[0].action_register_payment() - own_wizard = Form(self.env['account.payment.register'].with_context(own_payment['context'])).save() - own_wizard.action_create_payments() - sheet_own.account_move_ids.button_draft() - sheet_own.account_move_ids.unlink() - - with self.assertRaises(UserError, msg="For company-paid expenses report, deleting payments is an all-or-nothing situation"): - sheet_company.account_move_ids[:-1].payment_id.unlink() - - sheet_company.account_move_ids.payment_id.unlink() - self.assertRecordValues(sheets.sorted('payment_mode'), [ - {'payment_mode': 'company_account', 'state': 'approve', 'payment_state': 'not_paid', 'account_move_ids': self.env['account.move']}, - {'payment_mode': 'own_account', 'state': 'approve', 'payment_state': 'not_paid', 'account_move_ids': self.env['account.move']}, - ]) - - def test_multiple_payments(self): - # pylint: disable=bad-whitespace - foreign_currency = self.env['res.currency'].create({ - 'name': 'Exposure', - 'symbol': ' ', - 'rounding': 0.01, - 'position': 'after', - 'currency_unit_label': 'Nothing', - 'currency_subunit_label': 'Smaller Nothing', - }) - self.env['res.currency.rate'].create({ - 'name': '2016-01-01', - 'rate': 1/1.52890, - 'currency_id': foreign_currency.id, - 'company_id': self.company_data['company'].id, - }) - tax = self.env['account.tax'].create({ - 'name': 'Tax Expense 15%', - 'amount': 15, - 'amount_type': 'percent', - 'type_tax_use': 'purchase', - 'price_include': True, - }) - - expense_sheet_own_account = self.env['hr.expense.sheet'].create({ - 'name': "Own Expenses", - 'employee_id': self.expense_employee.id, - 'accounting_date': '2020-01-01', - 'expense_line_ids': [ - Command.create({ - 'name': 'Own expense', - 'date': '2022-11-16', - 'payment_mode': 'own_account', - 'total_amount': 1000.00, - 'employee_id': self.expense_employee.id, - 'product_id': self.product_c.id, - 'currency_id': self.company_data['currency'].id, - 'tax_ids': [Command.set(tax.ids)], - }), - Command.create({ - 'name': 'Own expense 2', - 'date': '2022-11-17', - 'payment_mode': 'own_account', - 'total_amount': 1000.00, - 'employee_id': self.expense_employee.id, - 'product_id': self.product_c.id, - 'currency_id': foreign_currency.id, # rate is 1:1.5289 - 'tax_ids': [Command.set(tax.ids)], - }) - ], - }) - expense_sheet_company_account = self.env['hr.expense.sheet'].create({ - 'name': "Company Expenses", - 'employee_id': self.expense_employee.id, - 'accounting_date': '2020-01-01', - 'expense_line_ids': [ - Command.create({ - 'name': 'Company expense', - 'date': '2022-11-16', - 'payment_mode': 'company_account', - 'total_amount': 1000.00, - 'employee_id': self.expense_employee.id, - 'product_id': self.product_c.id, - 'currency_id': self.company_data['currency'].id, - 'tax_ids': [Command.set(tax.ids)], - }), - Command.create({ - 'name': 'Company expense 2', - 'date': '2022-11-17', - 'payment_mode': 'company_account', - 'total_amount': 1000.00, - 'employee_id': self.expense_employee.id, - 'product_id': self.product_c.id, - 'currency_id': foreign_currency.id, # rate is 1:1.5289 - 'tax_ids': [Command.set(tax.ids)], - }), - ], - }) - - sheets = expense_sheet_own_account + expense_sheet_company_account - self.assertRecordValues(sheets.expense_line_ids, [ - {'total_amount': 1000.00, 'total_amount_company': 1000.00, 'amount_tax': 130.43, 'amount_tax_company': 130.43}, - {'total_amount': 1000.00, 'total_amount_company': 1528.90, 'amount_tax': 130.43, 'amount_tax_company': 199.42}, - {'total_amount': 1000.00, 'total_amount_company': 1000.00, 'amount_tax': 130.43, 'amount_tax_company': 130.43}, - {'total_amount': 1000.00, 'total_amount_company': 1528.90, 'amount_tax': 130.43, 'amount_tax_company': 199.42}, - ]) - - sheets.action_submit_sheet() - sheets.action_approve_expense_sheets() - sheets.action_sheet_move_create() - self.assertRecordValues(expense_sheet_own_account.account_move_ids, [{'ref': 'Own Expenses'}]) # One move with the report name in ref - self.assertRecordValues(expense_sheet_company_account.account_move_ids.sorted('ref'), [ - {'ref': 'Company expense'}, - {'ref': 'Company expense 2'}, - ]) # Two moves with the expense names in ref - self.assertRecordValues(expense_sheet_own_account.account_move_ids.line_ids, [ - {'balance': 1329.48, 'amount_currency': 1329.48, 'currency_id': self.company_data['currency'].id}, - {'balance': 869.57, 'amount_currency': 869.57, 'currency_id': self.company_data['currency'].id}, - {'balance': 199.42, 'amount_currency': 199.42, 'currency_id': self.company_data['currency'].id}, - {'balance': 130.43, 'amount_currency': 130.43, 'currency_id': self.company_data['currency'].id}, - {'balance': -2528.90, 'amount_currency': -2528.90, 'currency_id': self.company_data['currency'].id}, - ]) - - self.assertRecordValues(expense_sheet_company_account.account_move_ids.sorted('ref').line_ids, [ - # First move - {'balance': 869.57, 'amount_currency': 869.57, 'currency_id': self.company_data['currency'].id}, - {'balance': 130.43, 'amount_currency': 130.43, 'currency_id': self.company_data['currency'].id}, - {'balance': -1000.00, 'amount_currency': -1000.00, 'currency_id': self.company_data['currency'].id}, - # Second move - {'balance': 1329.49, 'amount_currency': 869.57, 'currency_id': foreign_currency.id}, # Values are different due conversion issues - {'balance': 199.41, 'amount_currency': 130.43, 'currency_id': foreign_currency.id}, - {'balance': -1528.90, 'amount_currency': -1000.00, 'currency_id': foreign_currency.id}, - ]) - - def test_currency_rate_override(self): - """ Checks that the currency rate is recomputed properly when the total in company currency is set to a new value """ - foreign_currency = self.env['res.currency'].create({ - 'name': 'Exposure', - 'symbol': ' ', - 'rounding': 0.01, - 'position': 'after', - 'currency_unit_label': 'Nothing', - 'currency_subunit_label': 'Smaller Nothing', - }) - self.env['res.currency.rate'].create({ - 'name': '2016-01-01', - 'rate': 1/1.52, - 'currency_id': foreign_currency.id, - 'company_id': self.company_data['company'].id, - }) - expense = self.env['hr.expense'].create({ - 'name': 'Company expense', - 'date': '2022-11-16', - 'payment_mode': 'company_account', - 'total_amount': 1000.00, - 'employee_id': self.expense_employee.id, - 'product_id': self.product_c.id, - 'currency_id': self.currency_data['currency'].id, # rate is 1:2 - }) - expense_2 = self.env['hr.expense'].create({ - 'name': 'Company expense', - 'date': '2022-11-16', - 'payment_mode': 'company_account', - 'total_amount': 1000.00, - 'employee_id': self.expense_employee.id, - 'product_id': self.product_c.id, - 'currency_id': foreign_currency.id, # rate is 1.52 - }) - expense_3 = self.env['hr.expense'].create({ - 'name': 'Company expense', - 'date': '2022-11-16', - 'payment_mode': 'company_account', - 'total_amount': 1000.00, - 'employee_id': self.expense_employee.id, - 'product_id': self.product_c.id, - 'currency_id': foreign_currency.id, # rate is 1.52 - }) - expenses = expense | expense_2 | expense_3 - self.assertRecordValues(expenses, [ - {'currency_rate': 0.50, 'total_amount': 1000.00, 'total_amount_company': 500.00, 'currency_id': self.currency_data['currency'].id}, - {'currency_rate': 1.52, 'total_amount': 1000.00, 'total_amount_company': 1520.00, 'currency_id': foreign_currency.id}, - {'currency_rate': 1.52, 'total_amount': 1000.00, 'total_amount_company': 1520.00, 'currency_id': foreign_currency.id}, - ]) - - # Changing rate on the two first expenses - expense.write({'total_amount_company': 1000.00}) - expense_2.write({'total_amount_company': 2000.00}) - self.assertRecordValues(expenses, [ - {'currency_rate': 1.00, 'total_amount': 1000.00, 'total_amount_company': 1000.00}, # Rate should change - {'currency_rate': 2.00, 'total_amount': 1000.00, 'total_amount_company': 2000.00}, # Rate should change - {'currency_rate': 1.52, 'total_amount': 1000.00, 'total_amount_company': 1520.00}, # Rate should NOT change - ]) - - # Sheet and move creation should not touch the rates anymore - expense_sheet = self.env['hr.expense.sheet'].create({ - 'name': 'Expense for Company', - 'employee_id': self.expense_employee.id, - 'expense_line_ids': expenses, - }) - expense_sheet.action_submit_sheet() - expense_sheet.action_approve_expense_sheets() - expense_sheet.action_sheet_move_create() - self.assertRecordValues(expense_sheet.account_move_ids, [ - {'amount_total_in_currency_signed': 1000.00, 'amount_total_signed': 1000.00, 'currency_id': self.currency_data['currency'].id}, - {'amount_total_in_currency_signed': 1000.00, 'amount_total_signed': 2000.00, 'currency_id': foreign_currency.id}, - {'amount_total_in_currency_signed': 1000.00, 'amount_total_signed': 1520.00, 'currency_id': foreign_currency.id}, - ]) - def test_attachments_in_move_from_own_expense(self): """ Checks that journal entries created form expense reports paid by employee have a copy of the attachments in the expense. """ expense = self.env['hr.expense'].create({ @@ -1541,14 +644,14 @@ class TestExpenses(TestExpenseCommon): 'name': 'Company expense', 'date': '2022-11-16', 'payment_mode': 'company_account', - 'total_amount': 1000.00, + 'total_amount_currency': 1000.00, 'employee_id': self.expense_employee.id, }) expense_2 = self.env['hr.expense'].create({ 'name': 'Company expense 2', 'date': '2022-11-16', 'payment_mode': 'company_account', - 'total_amount': 1000.00, + 'total_amount_currency': 1000.00, 'employee_id': self.expense_employee.id, }) attachment = self.env['ir.attachment'].create({ diff --git a/addons/hr_expense/tests/test_expenses_access_rights.py b/addons/hr_expense/tests/test_expenses_access_rights.py index 2357b264ce3..4bfb15df5e7 100644 --- a/addons/hr_expense/tests/test_expenses_access_rights.py +++ b/addons/hr_expense/tests/test_expenses_access_rights.py @@ -1,5 +1,5 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import Command from odoo.addons.hr_expense.tests.common import TestExpenseCommon from odoo.exceptions import AccessError, UserError from odoo.tests import tagged @@ -15,7 +15,6 @@ class TestExpensesAccessRights(TestExpenseCommon): 'name': 'expense_employee_2', 'user_id': self.env.user.id, 'work_contact_id': self.env.user.partner_id.id, - 'address_id': self.env.user.partner_id.id, }) with self.assertRaises(AccessError): @@ -24,95 +23,68 @@ class TestExpensesAccessRights(TestExpenseCommon): 'employee_id': expense_employee_2.id, 'product_id': self.product_a.id, 'quantity': 1, - 'unit_amount': 1, + 'price_unit': 1, }) - def test_expense_sheet_access_rights_approve(self): - + def test_expense_sheet_access_rights(self): # The expense employee is able to a create an expense sheet. - expense_sheet = self.env['hr.expense.sheet'].with_user(self.expense_user_employee).create({ + expense_sheet_approve = self.env['hr.expense.sheet'].with_user(self.expense_user_employee).create({ 'name': 'First Expense for employee', 'employee_id': self.expense_employee.id, 'journal_id': self.company_data['default_journal_purchase'].id, 'accounting_date': '2017-01-01', - 'expense_line_ids': [ - (0, 0, { - # Expense without foreign currency but analytic account. - 'name': 'expense_1', - 'date': '2016-01-01', - 'product_id': self.product_a.id, - 'unit_amount': 1000.0, - 'employee_id': self.expense_employee.id, - }), - ], + 'expense_line_ids': [Command.create({ + # Expense without foreign currency but analytic account. + 'name': 'expense_1', + 'date': '2016-01-01', + 'product_id': self.product_a.id, + 'price_unit': 1000.0, + 'employee_id': self.expense_employee.id, + })], }) - self.env.flush_all() - self.assertRecordValues(expense_sheet, [{'state': 'draft'}]) + expense_sheet_refuse = self.env['hr.expense.sheet'].with_user(self.expense_user_employee).create({ + 'name': 'First Expense for employee', + 'employee_id': self.expense_employee.id, + 'journal_id': self.company_data['default_journal_purchase'].id, + 'accounting_date': '2017-01-01', + 'expense_line_ids': [Command.create({ + # Expense without foreign currency but analytic account. + 'name': 'expense_1', + 'date': '2016-01-01', + 'product_id': self.product_a.id, + 'price_unit': 1000.0, + 'employee_id': self.expense_employee.id, + })], + }) + sheets = expense_sheet_approve | expense_sheet_refuse + + self.assertRecordValues(sheets, [{'state': 'draft'}, {'state': 'draft'}]) # The expense employee is able to submit the expense sheet. - - expense_sheet.with_user(self.expense_user_employee).action_submit_sheet() - self.assertRecordValues(expense_sheet, [{'state': 'submit'}]) + sheets.with_user(self.expense_user_employee).action_submit_sheet() + self.assertRecordValues(sheets, [{'state': 'submit'}, {'state': 'submit'}]) # The expense employee is not able to approve itself the expense sheet. + with self.assertRaises(UserError): + expense_sheet_approve.with_user(self.expense_user_employee).action_approve_expense_sheets() with self.assertRaises(UserError): - expense_sheet.with_user(self.expense_user_employee).action_approve_expense_sheets() - self.assertRecordValues(expense_sheet, [{'state': 'submit'}]) + expense_sheet_refuse.with_user(self.expense_user_employee).action_refuse_expense_sheets() + self.assertRecordValues(sheets, [{'state': 'submit'}, {'state': 'submit'}]) # An expense manager is required for this step. - - expense_sheet.with_user(self.expense_user_manager).action_approve_expense_sheets() - self.assertRecordValues(expense_sheet, [{'state': 'approve'}]) + expense_sheet_approve.with_user(self.expense_user_manager).action_approve_expense_sheets() + expense_sheet_refuse.with_user(self.expense_user_manager).action_refuse_expense_sheets() + expense_sheet_refuse.with_user(self.expense_user_manager)._do_refuse('failed') + self.assertRecordValues(sheets, [{'state': 'approve'}, {'state': 'cancel'}]) # An expense manager is not able to create the journal entry. - with self.assertRaises(AccessError): - expense_sheet.with_user(self.expense_user_manager).action_sheet_move_create() - self.assertRecordValues(expense_sheet, [{'state': 'approve'}]) + expense_sheet_approve.with_user(self.expense_user_manager).action_sheet_move_create() + self.assertRecordValues(expense_sheet_approve, [{'state': 'approve'}]) # An expense manager having accounting access rights is able to create the journal entry. - - expense_sheet.with_user(self.env.user).action_sheet_move_create() - self.assertRecordValues(expense_sheet, [{'state': 'post'}]) - - def test_expense_sheet_access_rights_refuse(self): - - # The expense employee is able to a create an expense sheet. - - expense_sheet = self.env['hr.expense.sheet'].with_user(self.expense_user_employee).create({ - 'name': 'First Expense for employee', - 'employee_id': self.expense_employee.id, - 'journal_id': self.company_data['default_journal_purchase'].id, - 'accounting_date': '2017-01-01', - 'expense_line_ids': [ - (0, 0, { - # Expense without foreign currency but analytic account. - 'name': 'expense_1', - 'date': '2016-01-01', - 'product_id': self.product_a.id, - 'unit_amount': 1000.0, - 'employee_id': self.expense_employee.id, - }), - ], - }) - self.assertRecordValues(expense_sheet, [{'state': 'draft'}]) - - # The expense employee is able to submit the expense sheet. - - expense_sheet.with_user(self.expense_user_employee).action_submit_sheet() - self.assertRecordValues(expense_sheet, [{'state': 'submit'}]) - - # The expense employee is not able to refuse itself the expense sheet. - - with self.assertRaises(UserError): - expense_sheet.with_user(self.expense_user_employee).action_refuse_expense_sheets() - self.assertRecordValues(expense_sheet, [{'state': 'submit'}]) - - # An expense manager is required for this step. - - expense_sheet.with_user(self.expense_user_manager).action_refuse_expense_sheets() - expense_sheet.with_user(self.expense_user_manager)._do_refuse('failed') - self.assertRecordValues(expense_sheet, [{'state': 'cancel'}]) + expense_sheet_approve.with_user(self.env.user).action_sheet_move_create() + self.assertRecordValues(expense_sheet_approve, [{'state': 'post'}]) diff --git a/addons/hr_expense/tests/test_expenses_mail_import.py b/addons/hr_expense/tests/test_expenses_mail_import.py index e8acd640532..4a78143c189 100644 --- a/addons/hr_expense/tests/test_expenses_mail_import.py +++ b/addons/hr_expense/tests/test_expenses_mail_import.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo.addons.hr_expense.tests.common import TestExpenseCommon from odoo.tests import tagged @@ -6,85 +5,51 @@ from odoo.tests import tagged @tagged('-at_install', 'post_install') class TestExpensesMailImport(TestExpenseCommon): - - @classmethod - def setUpClass(cls, chart_template_ref=None): - super().setUpClass(chart_template_ref=chart_template_ref) - - cls.product_a.default_code = 'product_a' - cls.product_b.default_code = 'product_b' - def test_import_expense_from_email(self): - message_parsed = { - 'message_id': "the-world-is-a-ghetto", - 'subject': '%s %s' % (self.product_a.default_code, self.product_a.standard_price), - 'email_from': self.expense_user_employee.email, - 'to': 'catchall@yourcompany.com', - 'body': "Don't you know, that for me, and for you", - 'attachments': [], - } + # pylint: disable=bad-whitespace + messages = ({ + 'message_id': "the-world-is-a-ghetto", + 'subject': f'{self.product_a.default_code} {self.product_a.standard_price}', + 'email_from': self.expense_user_employee.email, + 'to': 'catchall@yourcompany.com', + 'body': "Don't you know, that for me, and for you", + 'attachments': [], + }, { + 'message_id': "the-world-is-a-ghetto", + 'subject': 'no product code 800', + 'email_from': self.expense_user_employee.email, + 'to': 'catchall@yourcompany.com', + 'body': "Don't you know, that for me, and for you", + 'attachments': [], + }, { + 'message_id': "test", + 'subject': 'product_c my description 100', + 'email_from': self.expense_user_employee.email, + 'to': 'catchall@yourcompany.com', + 'body': "test", + 'attachments': [], + } + ) + expenses = self.env['hr.expense'] + for message in messages: + expenses |= self.env['hr.expense'].message_new(message) - expense = self.env['hr.expense'].message_new(message_parsed) - self.assertRecordValues(expense, [{ - 'product_id': self.product_a.id, - 'total_amount': 800.0, - 'employee_id': self.expense_employee.id, - }]) - - def test_import_expense_from_email_no_product(self): - message_parsed = { - 'message_id': "the-world-is-a-ghetto", - 'subject': 'no product code 800', - 'email_from': self.expense_user_employee.email, - 'to': 'catchall@yourcompany.com', - 'body': "Don't you know, that for me, and for you", - 'attachments': [], - } - - expense = self.env['hr.expense'].message_new(message_parsed) - - self.assertRecordValues(expense, [{ - 'product_id': False, - 'total_amount': 800.0, - 'employee_id': self.expense_employee.id, - }]) - - def test_import_expense_from_email_product_no_cost(self): - """ - We have to compute a value for the total amount - even if the product has no cost. - """ - product_no_cost = self.env['product.product'].create({ - 'name': 'Product No Cost', - 'standard_price': 0.0, - 'can_be_expensed': True, - 'default_code': 'product_no_cost', - }) - message_parsed = { - 'message_id': "test", - 'subject': 'product_no_cost my description 100', - 'email_from': self.expense_user_employee.email, - 'to': 'catchall@yourcompany.com', - 'body': "test", - 'attachments': [], - } - expense = self.env['hr.expense'].message_new(message_parsed) - self.assertRecordValues(expense, [{ - 'product_id': product_no_cost.id, - 'total_amount': 100.0, - 'employee_id': self.expense_employee.id, - }]) + self.assertRecordValues(expenses, [ + {'product_id': self.product_a.id, 'total_amount_currency': 800.0, 'employee_id': self.expense_employee.id}, + {'product_id': False, 'total_amount_currency': 800.0, 'employee_id': self.expense_employee.id}, + {'product_id': self.product_c.id, 'total_amount_currency': 100.0, 'employee_id': self.expense_employee.id}, + ]) def test_import_expense_from_mail_parsing_subjects(self): - - def assertParsedValues(subject, currencies, exp_description, exp_amount, exp_product): - product, amount, currency_id, description = self.env['hr.expense']\ - .with_user(self.expense_user_employee)\ + def assertParsedValues(subject, currencies, exp_description, exp_amount, exp_product, exp_currency): + product, amount, currency_id, description = self.env['hr.expense'] \ + .with_user(self.expense_user_employee) \ ._parse_expense_subject(subject, currencies) self.assertEqual(product, exp_product) self.assertAlmostEqual(amount, exp_amount) self.assertEqual(description, exp_description) + self.assertEqual(currency_id, exp_currency) # Without Multi currency access assertParsedValues( @@ -93,35 +58,37 @@ class TestExpensesMailImport(TestExpenseCommon): "bar electro wizard", 1205.91, self.product_a, + self.company_data['currency'], ) # subject having other currency then company currency, it should ignore other currency then company currency assertParsedValues( - "foo bar %s1406.91 royal giant" % self.currency_data['currency'].symbol, + f'foo bar {self.currency_data["currency"].symbol}1406.91 royal giant', self.company_data['currency'], - "foo bar %s royal giant" % self.currency_data['currency'].symbol, + f'foo bar {self.currency_data["currency"].symbol} royal giant', 1406.91, self.env['product.product'], + self.company_data['currency'], ) # With Multi currency access self.expense_user_employee.groups_id |= self.env.ref('base.group_multi_currency') - assertParsedValues( "product_a foo bar $2205.92 elite barbarians", self.company_data['currency'], "foo bar elite barbarians", 2205.92, self.product_a, + self.company_data['currency'], ) - # subject having other currency then company currency, it should accept other currency because multi currency is activated assertParsedValues( - "product_a %s2510.90 chhota bheem" % self.currency_data['currency'].symbol, + f'product_a {self.currency_data["currency"].symbol}2510.90 chhota bheem', self.company_data['currency'] + self.currency_data['currency'], "chhota bheem", 2510.90, self.product_a, + self.currency_data['currency'], ) # subject without product and currency, should take company currency and default product @@ -131,6 +98,7 @@ class TestExpensesMailImport(TestExpenseCommon): "foo bar spear goblins", 109.96, self.env['product.product'], + self.company_data['currency'], ) # subject with currency symbol at end @@ -140,6 +108,7 @@ class TestExpensesMailImport(TestExpenseCommon): "foo bar inferno dragon", 2910.94, self.product_a, + self.company_data['currency'], ) # subject with no amount and product @@ -149,6 +118,7 @@ class TestExpensesMailImport(TestExpenseCommon): "foo bar mega knight", 0.0, self.env['product.product'], + self.company_data['currency'], ) # price with a comma @@ -158,21 +128,23 @@ class TestExpensesMailImport(TestExpenseCommon): "foo bar mega knight", 291.56, self.env['product.product'], + self.company_data['currency'], ) - # price without decimals + # price different decimals than currency assertParsedValues( "foo bar 291$ mega knight", self.company_data['currency'] + self.currency_data['currency'], "foo bar mega knight", 291.0, self.env['product.product'], + self.company_data['currency'], ) - assertParsedValues( "product_a foo bar 291.5$ mega knight", self.company_data['currency'] + self.currency_data['currency'], "foo bar mega knight", 291.5, self.product_a, + self.company_data['currency'], ) diff --git a/addons/hr_expense/tests/test_expenses_multi_company.py b/addons/hr_expense/tests/test_expenses_multi_company.py index e24ca4a4dfd..690c5dca945 100644 --- a/addons/hr_expense/tests/test_expenses_multi_company.py +++ b/addons/hr_expense/tests/test_expenses_multi_company.py @@ -1,118 +1,97 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import Command from odoo.addons.hr_expense.tests.common import TestExpenseCommon -from odoo.tests import tagged from odoo.exceptions import UserError +from odoo.tests import tagged @tagged('post_install', '-at_install') class TestExpenseMultiCompany(TestExpenseCommon): - def test_expense_sheet_multi_company_approve(self): + def test_expense_sheet_multi_company(self): self.expense_employee.company_id = self.company_data_2['company'] # The expense employee is able to a create an expense sheet for company_2. # product_a needs a standard_price in company_2 self.product_a.with_context(allowed_company_ids=self.company_data_2['company'].ids).standard_price = 100 - expense_sheet = self.env['hr.expense.sheet']\ - .with_user(self.expense_user_employee)\ - .with_context(allowed_company_ids=self.company_data_2['company'].ids)\ + expense_sheet_approve = self.env['hr.expense.sheet'] \ + .with_user(self.expense_user_employee) \ + .with_context(allowed_company_ids=self.company_data_2['company'].ids) \ .create({ - 'name': 'First Expense for employee', + 'name': 'First Expense for employee', + 'employee_id': self.expense_employee.id, + 'journal_id': self.company_data_2['default_journal_purchase'].id, + 'accounting_date': '2017-01-01', + 'expense_line_ids': [Command.create({ + # Expense without foreign currency but analytic account. + 'name': 'expense_1', + 'date': '2016-01-01', + 'product_id': self.product_a.id, + 'price_unit': 1000.0, 'employee_id': self.expense_employee.id, - 'journal_id': self.company_data_2['default_journal_purchase'].id, - 'accounting_date': '2017-01-01', - 'expense_line_ids': [ - (0, 0, { - # Expense without foreign currency but analytic account. - 'name': 'expense_1', - 'date': '2016-01-01', - 'product_id': self.product_a.id, - 'unit_amount': 1000.0, - 'employee_id': self.expense_employee.id, - }), - ], - }) - self.assertRecordValues(expense_sheet, [{'company_id': self.company_data_2['company'].id}]) + })], + }) + expense_sheet_refuse = self.env['hr.expense.sheet'] \ + .with_user(self.expense_user_employee) \ + .with_context(allowed_company_ids=self.company_data_2['company'].ids) \ + .create({ + 'name': 'First Expense for employee', + 'employee_id': self.expense_employee.id, + 'journal_id': self.company_data_2['default_journal_purchase'].id, + 'accounting_date': '2017-01-01', + 'expense_line_ids': [Command.create({ + # Expense without foreign currency but analytic account. + 'name': 'expense_1', + 'date': '2016-01-01', + 'product_id': self.product_a.id, + 'price_unit': 1000.0, + 'employee_id': self.expense_employee.id, + })], + }) + expenses = expense_sheet_approve | expense_sheet_refuse + self.assertRecordValues(expenses, [ + {'company_id': self.company_data_2['company'].id}, + {'company_id': self.company_data_2['company'].id}, + ]) # The expense employee is able to submit the expense sheet. + expenses.with_user(self.expense_user_employee).action_submit_sheet() - expense_sheet.with_user(self.expense_user_employee).action_submit_sheet() - - # An expense manager is not able to approve without access to company_2. - + # An expense manager is not able to approve nor refuse without access to company_2. with self.assertRaises(UserError): - expense_sheet\ - .with_user(self.expense_user_manager)\ - .with_context(allowed_company_ids=self.company_data['company'].ids)\ + expense_sheet_approve \ + .with_user(self.expense_user_manager) \ + .with_context(allowed_company_ids=self.company_data['company'].ids) \ .action_approve_expense_sheets() - # An expense manager is able to approve with access to company_2. + with self.assertRaises(UserError): + expense_sheet_refuse \ + .with_user(self.expense_user_manager) \ + .with_context(allowed_company_ids=self.company_data['company'].ids) \ + ._do_refuse('failed') - expense_sheet\ - .with_user(self.expense_user_manager)\ - .with_context(allowed_company_ids=self.company_data_2['company'].ids)\ + # An expense manager is able to approve/refuse with access to company_2. + expense_sheet_approve \ + .with_user(self.expense_user_manager) \ + .with_context(allowed_company_ids=self.company_data_2['company'].ids) \ .action_approve_expense_sheets() + expense_sheet_refuse \ + .with_user(self.expense_user_manager) \ + .with_context(allowed_company_ids=self.company_data_2['company'].ids) \ + ._do_refuse('failed') # An expense manager having accounting access rights is not able to create the journal entry without access # to company_2. - with self.assertRaises(UserError): - expense_sheet\ - .with_user(self.env.user)\ - .with_context(allowed_company_ids=self.company_data['company'].ids)\ + expense_sheet_approve \ + .with_user(self.env.user) \ + .with_context(allowed_company_ids=self.company_data['company'].ids) \ .action_sheet_move_create() # An expense manager having accounting access rights is able to create the journal entry with access to # company_2. - - expense_sheet\ - .with_user(self.env.user)\ - .with_context(allowed_company_ids=self.company_data_2['company'].ids)\ + expense_sheet_approve \ + .with_user(self.env.user) \ + .with_context(allowed_company_ids=self.company_data_2['company'].ids) \ .action_sheet_move_create() - - def test_expense_sheet_multi_company_refuse(self): - self.expense_employee.company_id = self.company_data_2['company'] - - # The expense employee is able to a create an expense sheet for company_2. - - expense_sheet = self.env['hr.expense.sheet']\ - .with_user(self.expense_user_employee)\ - .with_context(allowed_company_ids=self.company_data_2['company'].ids)\ - .create({ - 'name': 'First Expense for employee', - 'employee_id': self.expense_employee.id, - 'journal_id': self.company_data_2['default_journal_purchase'].id, - 'accounting_date': '2017-01-01', - 'expense_line_ids': [ - (0, 0, { - # Expense without foreign currency but analytic account. - 'name': 'expense_1', - 'date': '2016-01-01', - 'product_id': self.product_a.id, - 'unit_amount': 1000.0, - 'employee_id': self.expense_employee.id, - }), - ], - }) - self.assertRecordValues(expense_sheet, [{'company_id': self.company_data_2['company'].id}]) - - # The expense employee is able to submit the expense sheet. - - expense_sheet.with_user(self.expense_user_employee).action_submit_sheet() - - # An expense manager is not able to approve without access to company_2. - - with self.assertRaises(UserError): - expense_sheet\ - .with_user(self.expense_user_manager)\ - .with_context(allowed_company_ids=self.company_data['company'].ids)\ - ._do_refuse('failed') - - # An expense manager is able to approve with access to company_2. - - expense_sheet\ - .with_user(self.expense_user_manager)\ - .with_context(allowed_company_ids=self.company_data_2['company'].ids)\ - ._do_refuse('failed') diff --git a/addons/hr_expense/tests/test_expenses_tax.py b/addons/hr_expense/tests/test_expenses_tax.py index 6cf57a697e6..81c3ff32df9 100644 --- a/addons/hr_expense/tests/test_expenses_tax.py +++ b/addons/hr_expense/tests/test_expenses_tax.py @@ -20,8 +20,8 @@ class TestExpensesTax(TestExpenseCommon): self.env['hr.expense'].create({ 'name': 'Test Tax Used', 'employee_id': self.expense_employee.id, - 'product_id': self.product_a.id, - 'unit_amount': 350.00, + 'product_id': self.product_c.id, + 'total_amount_currency': 350.00, 'tax_ids': [Command.set(tax_expense.ids)] }) tax_expense.invalidate_model(fnames=['is_used']) diff --git a/addons/hr_expense/views/hr_department_views.xml b/addons/hr_expense/views/hr_department_views.xml index bf64dee6260..01960ed7563 100644 --- a/addons/hr_expense/views/hr_department_views.xml +++ b/addons/hr_expense/views/hr_department_views.xml @@ -21,7 +21,8 @@ - + Expenses diff --git a/addons/hr_expense/views/hr_expense_views.xml b/addons/hr_expense/views/hr_expense_views.xml index 688e2793caf..af1e5b8202c 100644 --- a/addons/hr_expense/views/hr_expense_views.xml +++ b/addons/hr_expense/views/hr_expense_views.xml @@ -45,43 +45,48 @@ hr.expense + - - - - - - - - - + + + + + + + + + - + readonly="not is_editable"/> - - - + readonly="not is_editable"/> + + + - + - - + - + - @@ -120,10 +125,12 @@
- @@ -776,23 +831,31 @@ - + - + - - + - + @@ -817,39 +880,45 @@ readonly="not is_editable" force_save="1"> - + - - + + + - - - - -