From 81fe11fabb60de56241ea912a65f41a2d9f8538f Mon Sep 17 00:00:00 2001 From: "Julien Alardot (jual)" Date: Mon, 26 Jun 2023 12:08:12 +0000 Subject: [PATCH] [IMP] hr_expense: Split company payments per expense When an expense sheet move is created, there is only one outstanding line, but it is usual to pay separately. That makes the reconciliation step harder. This makes sure one move is created per expense Task-3328877 closes odoo/odoo#126805 Related: odoo/upgrade#4873 Signed-off-by: Laurent Smet (las) --- addons/hr_expense/models/account_move.py | 27 +- addons/hr_expense/models/account_payment.py | 11 +- addons/hr_expense/models/hr_expense.py | 368 +++++++++-------- addons/hr_expense/tests/test_expenses.py | 381 ++++++++++++++++-- .../hr_expense/views/account_move_views.xml | 2 +- .../views/account_payment_views.xml | 2 +- addons/hr_expense/views/hr_expense_views.xml | 17 +- .../wizard/account_payment_register.py | 6 +- 8 files changed, 583 insertions(+), 231 deletions(-) diff --git a/addons/hr_expense/models/account_move.py b/addons/hr_expense/models/account_move.py index 61f9b707b53..71acedefc42 100644 --- a/addons/hr_expense/models/account_move.py +++ b/addons/hr_expense/models/account_move.py @@ -1,17 +1,16 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from collections import defaultdict from markupsafe import escape from odoo import models, fields, api, _ +from odoo.exceptions import UserError from odoo.tools.misc import frozendict class AccountMove(models.Model): _inherit = "account.move" - expense_sheet_id = fields.One2many('hr.expense.sheet', 'account_move_id') + expense_sheet_id = fields.Many2one('hr.expense.sheet', ondelete='set null', copy=False) def action_open_expense_report(self): self.ensure_one() @@ -24,16 +23,6 @@ class AccountMove(models.Model): 'res_id': self.expense_sheet_id.id } - # Behave exactly like a receipt for everything except the display - # This enables the synchronisation of payment terms, and sets the taxes and accounts based on the product - def is_purchase_document(self, include_receipts=False): - return bool(include_receipts and self.sudo().expense_sheet_id) or super().is_purchase_document(include_receipts) - - def is_entry(self): - if self.expense_sheet_id: - return False - return super().is_entry() - # Expenses can be written on journal other than purchase, hence don't include them in the constraint check def _check_journal_move_type(self): return super(AccountMove, self.filtered(lambda x: not x.expense_sheet_id))._check_journal_move_type() @@ -67,7 +56,13 @@ class AccountMove(models.Model): } def _reverse_moves(self, default_values_list=None, cancel=False): - if self.expense_sheet_id: - self.expense_sheet_id = False - self.ref = False # else, when restarting the expense flow we get duplicate issue on vendor.bill + own_expense_moves = self.filtered(lambda move: move.expense_sheet_id.payment_mode == 'own_account') + own_expense_moves.write({'expense_sheet_id': False, 'ref': False}) + # else, when restarting the expense flow we get duplicate issue on vendor.bill return super()._reverse_moves(default_values_list=default_values_list, cancel=cancel) + + def unlink(self): + # EXTENDS account + if self.expense_sheet_id and self.expense_sheet_id.account_move_ids - self: # If not all the payments are to be deleted + raise UserError(_("You cannot delete only some entries linked to an expense report. All entries must be deleted at the same time.")) + return super().unlink() diff --git a/addons/hr_expense/models/account_payment.py b/addons/hr_expense/models/account_payment.py index 6f338878d79..22eb648121e 100644 --- a/addons/hr_expense/models/account_payment.py +++ b/addons/hr_expense/models/account_payment.py @@ -1,10 +1,11 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from markupsafe import escape + from odoo import models, _ from odoo.exceptions import UserError + class AccountPayment(models.Model): _inherit = "account.payment" @@ -27,7 +28,7 @@ class AccountPayment(models.Model): # Context is not enough, as we want to be able to delete # and update those entries later on. return - return super()._synchronize_from_moves(changed_fields) + super()._synchronize_from_moves(changed_fields) def _synchronize_to_moves(self, changed_fields): # EXTENDS account @@ -41,3 +42,9 @@ class AccountPayment(models.Model): if self.move_id.expense_sheet_id: return escape(_("Payment created for: %s")) % self.move_id.expense_sheet_id._get_html_link() return super()._creation_message() + + def unlink(self): + # EXTENDS account + if self.expense_sheet_id and self.expense_sheet_id.account_move_ids.payment_ids - self: # If not all the payments are to be deleted + raise UserError(_("You cannot delete only some payments linked to an expense report. All payments must be deleted at the same time.")) + return super().unlink() diff --git a/addons/hr_expense/models/hr_expense.py b/addons/hr_expense/models/hr_expense.py index dee9373942d..747a49dbf0a 100644 --- a/addons/hr_expense/models/hr_expense.py +++ b/addons/hr_expense/models/hr_expense.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import re @@ -154,7 +153,7 @@ class HrExpense(models.Model): tax_ids = expense.product_id.supplier_taxes_id.filtered(lambda tax: tax.company_id == expense.company_id) expense.product_has_tax = bool(tax_ids) - @api.depends('sheet_id', 'sheet_id.account_move_id', 'sheet_id.state') + @api.depends('sheet_id', 'sheet_id.account_move_ids', 'sheet_id.state') def _compute_state(self): for expense in self: if not expense.sheet_id: @@ -165,7 +164,7 @@ class HrExpense(models.Model): expense.state = 'refused' elif expense.sheet_id.state in {'approve', 'post'}: expense.state = 'approved' - elif not expense.sheet_id.account_move_id: + elif not expense.sheet_id.account_move_ids: expense.state = 'submitted' else: expense.state = 'done' @@ -205,12 +204,6 @@ class HrExpense(models.Model): extra_context={'force_price_include': True}, ) - def _get_taxes_results(self, price, quantity, currency): - # Deprecated - self.ensure_one() - return self.tax_ids.with_context(force_price_include=True, round=True) \ - .compute_all(price, self.currency_id, quantity, self.product_id, self.employee_id.user_id.partner_id) - @api.depends('currency_rate', 'total_amount', 'tax_ids', 'product_id', 'employee_id.user_id.partner_id', 'quantity') def _compute_total_amount_company(self): for expense in self: @@ -435,7 +428,7 @@ class HrExpense(models.Model): 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(HrExpense, self).write(vals) + 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. @@ -511,7 +504,7 @@ class HrExpense(models.Model): dates = todo.mapped('date') min_date = format_date(self.env, min(dates)) max_date = format_date(self.env, max(dates)) - expense_name = min_date if max_date == min_date else "%s - %s" % (min_date, max_date) + expense_name = min_date if max_date == min_date else f'{min_date} - {max_date}' vals = { 'company_id': self.company_id.id, @@ -536,28 +529,28 @@ class HrExpense(models.Model): def action_submit_expenses(self): context_vals = self._get_default_expense_sheet_values() + action_values = { + 'name': _('New Expense Reports'), + 'type': 'ir.actions.act_window', + 'res_model': 'hr.expense.sheet', + } if len(context_vals) > 1: sheets = self.env['hr.expense.sheet'].create(context_vals) - return { - 'name': _('New Expense Reports'), - 'type': 'ir.actions.act_window', + action_values.update({ 'views': [[False, "list"], [False, "form"]], - 'res_model': 'hr.expense.sheet', 'domain': [('id', 'in', sheets.ids)], 'context': self.env.context, - } + }) else: context_vals_def = {} for key in context_vals[0]: context_vals_def['default_' + key] = context_vals[0][key] - return { - 'name': _('New Expense Report'), - 'type': 'ir.actions.act_window', + action_values.update({ 'views': [[False, "form"]], - 'res_model': 'hr.expense.sheet', 'target': 'current', - 'context': context_vals_def, - } + 'context': {f'default_{key}': value for key, value in context_vals[0].items()}, + }) + return action_values def action_get_attachment_view(self): self.ensure_one() @@ -615,7 +608,86 @@ class HrExpense(models.Model): # Business # ---------------------------------------- - def _prepare_move_line_vals(self): + def _prepare_payments_vals(self): + self.ensure_one() + + journal = self.sheet_id.journal_id + payment_method_line = self.env['account.payment.method.line'].search( + [('payment_type', '=', 'outbound'), + ('journal_id', '=', journal.id), + ('code', '=', 'manual'), + ('company_id', '=', self.company_id.id)], limit=1) + if not payment_method_line: + raise UserError(_("You need to add a manual payment method on the journal (%s)", journal.name)) + if self.currency_id != self.company_currency_id: + total_amount_currency = self.total_amount + rate = total_amount_currency / self.total_amount_company if self.total_amount_company else 1.0 + else: + total_amount_currency = self.total_amount_company + rate = 1 + move_lines = [] + # Due to rounding and conversion mismatch between vendor bills and payments, we have to force the computation into company account + amount_currency_diff = total_amount_currency + last_expense_line = None # Used for rounding in currency issues + tax_data = self.env['account.tax']._compute_taxes([self._convert_to_tax_base_line_dict(currency=self.sheet_id.currency_id)]) + for base_line_data, update in tax_data['base_lines_to_update']: # Add base lines + base_line_data.update(update) + amount_currency = self.currency_id.round(base_line_data['price_subtotal'] * rate) + expense_name = self.name.split("\n")[0][:64] + last_expense_line = base_move_line = { + 'name': f'{self.employee_id.name}: {expense_name}', + 'account_id': base_line_data['account'].id, + 'analytic_distribution': base_line_data['analytic_distribution'], + 'expense_id': self.id, + 'tax_ids': [Command.set(self.tax_ids.ids)], + 'tax_tag_ids': base_line_data['tax_tag_ids'], + 'balance': base_line_data['price_subtotal'], + 'amount_currency': amount_currency, + 'currency_id': self.currency_id.id, + 'product_id': base_line_data['product'].id, + } + amount_currency_diff -= amount_currency + move_lines.append(base_move_line) + for tax_line_data in tax_data['tax_lines_to_add']: # Add tax lines + amount_currency = self.sheet_id.currency_id.round(tax_line_data['tax_amount'] * rate) + last_expense_line = tax_line = { + 'name': self.env['account.tax'].browse(tax_line_data['tax_id']).name, + 'account_id': tax_line_data['account_id'], + 'analytic_distribution': tax_line_data['analytic_distribution'], + 'expense_id': self.id, + 'tax_tag_ids': tax_line_data['tax_tag_ids'], + 'balance': tax_line_data['tax_amount'], + 'amount_currency': amount_currency, + 'tax_base_amount': tax_line_data['base_amount'], + 'currency_id': self.currency_id.id, + 'tax_repartition_line_id': tax_line_data['tax_repartition_line_id'], + } + move_lines.append(tax_line) + amount_currency_diff -= amount_currency + if not self.sheet_id.currency_id.is_zero(amount_currency_diff) and last_expense_line: + last_expense_line['amount_currency'] += amount_currency_diff + 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(-total_amount_currency), + 'currency_id': self.currency_id.id, + }) + return { + **self.sheet_id._prepare_move_vals(), + 'ref': self.name, + 'journal_id': journal.id, + 'move_type': 'entry', + 'amount': total_amount_currency, + 'payment_type': 'outbound', + 'partner_type': 'supplier', + 'payment_method_line_id': payment_method_line.id, + 'currency_id': self.currency_id.id, + 'line_ids': [Command.create(line) for line in move_lines], + } + + def _prepare_move_lines_vals(self): self.ensure_one() account = self.account_id if not account: @@ -625,9 +697,9 @@ class HrExpense(models.Model): account = self.product_id.product_tmpl_id._get_product_accounts()['expense'] else: account = self.env['ir.property']._get('property_account_expense_categ_id', 'product.category') - + expense_name = self.name.split('\n')[0][:64] return { - 'name': self.employee_id.name + ': ' + self.name.split('\n')[0][:64], + 'name': f'{self.employee_id.name}: {expense_name}', 'account_id': account.id, 'quantity': self.quantity or 1, 'price_unit': self.unit_amount, @@ -681,8 +753,7 @@ class HrExpense(models.Model): if state in {'draft', 'reported'}: # Fusion 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()) + amount = currency._convert(total_amount_sum, target_currency, self.env.company, fields.Date.today()) expense_state[state]['amount'] += amount return expense_state @@ -736,7 +807,7 @@ class HrExpense(models.Model): if account: vals['account_id'] = account.id - expense = super(HrExpense, self).message_new(msg_dict, dict(custom_values or {}, **vals)) + expense = super().message_new(msg_dict, dict(custom_values or {}, **vals)) self._send_expense_success_mail(msg_dict, expense) return expense @@ -812,7 +883,7 @@ class HrExpense(models.Model): body=body, email_layout_xmlid='mail.mail_notification_light', partner_ids=expense.employee_id.user_id.partner_id.ids, - subject='Re: %s' % msg_dict.get('subject', ''), + subject=f'Re: {msg_dict.get("subject", "")}', subtype_xmlid='mail.mt_note', ) else: @@ -823,7 +894,7 @@ class HrExpense(models.Model): 'email_from': self.env.user.email_formatted, 'email_to': msg_dict.get('email_from', False), 'references': msg_dict.get('message_id'), - 'subject': 'Re: %s' % msg_dict.get('subject', ''), + 'subject': f'Re: {msg_dict.get("subject", "")}', }).send() @@ -871,6 +942,10 @@ class HrExpenseSheet(models.Model): expense_line_ids = fields.One2many('hr.expense', 'sheet_id', string='Expense Lines', copy=False) product_ids = fields.Many2many('product.product', compute='_compute_product_ids', search='_search_product_ids', string='Categories') 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'), @@ -887,19 +962,18 @@ class HrExpenseSheet(models.Model): approval_date = fields.Datetime('Approval Date', readonly=True) company_id = fields.Many2one('res.company', string='Company', required=True, readonly=True, states={'draft': [('readonly', False)]}, 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, readonly=True, tracking=True, states={'draft': [('readonly', False)]}, default=_default_employee_id, check_company=True, domain=lambda self: self.env['hr.expense']._get_employee_id_domain()) address_id = fields.Many2one('res.partner', compute='_compute_from_employee_id', store=True, readonly=False, copy=True, string="Employee Home Address", check_company=True) department_id = fields.Many2one('hr.department', compute='_compute_from_employee_id', store=True, readonly=False, copy=False, string='Department', states={'post': [('readonly', True)], 'done': [('readonly', True)]}) user_id = fields.Many2one('res.users', 'Manager', compute='_compute_from_employee_id', store=True, readonly=True, copy=False, states={'draft': [('readonly', False)]}, tracking=True, domain=lambda self: [('groups_id', 'in', self.env.ref('hr_expense.group_hr_expense_team_approver').id)]) # === Amount fields === # - total_amount = fields.Monetary('Total', currency_field='currency_id', compute='_compute_amount', store=True, tracking=True) - untaxed_amount = fields.Monetary('Untaxed Amount', currency_field='currency_id', compute='_compute_amount', store=True) - total_amount_taxes = fields.Monetary('Taxes', currency_field='currency_id', compute='_compute_amount', store=True) - amount_residual = fields.Monetary( - string="Amount Due", store=True, - currency_field='currency_id', - compute='_compute_from_account_move_id') + 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', states={'draft': [('readonly', False)]}, compute='_compute_currency_id', store=True, readonly=True) @@ -911,7 +985,7 @@ class HrExpenseSheet(models.Model): string="Payment Status", copy=False, tracking=True, - store=True, readonly=True, compute='_compute_from_account_move_id') + store=True, readonly=True, compute='_compute_from_account_move_ids') payment_mode = fields.Selection( string="Paid By", tracking=True, @@ -930,7 +1004,7 @@ class HrExpenseSheet(models.Model): help="The payment method used when the expense is paid by the company.", ) accounting_date = fields.Date("Accounting Date") - account_move_id = fields.Many2one('account.move', string='Journal Entry', ondelete='set null', copy=False, readonly=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) # === Security fields === # @@ -950,18 +1024,35 @@ class HrExpenseSheet(models.Model): 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_id.payment_state', 'account_move_id.amount_residual') - def _compute_from_account_move_id(self): + @api.depends('account_move_ids.payment_state', 'account_move_ids.amount_residual') + def _compute_from_account_move_ids(self): for sheet in self: - amount = sheet.account_move_id.amount_residual - state = sheet.account_move_id.payment_state or 'not_paid' - # when the sheet is paid by the company, the state/amount of - # the related account_move_id is not relevant - if sheet.account_move_id and sheet.payment_mode == 'company_account': - state = 'paid' - amount = 0.0 - sheet.payment_state = state - sheet.amount_residual = amount + 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): @@ -980,24 +1071,25 @@ class HrExpenseSheet(models.Model): 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_id', 'payment_state', 'approval_state') + @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_id: + elif sheet.account_move_ids: sheet.state = 'post' elif sheet.approval_state: sheet.state = sheet.approval_state else: sheet.state = 'draft' - @api.depends('company_id.currency_id') + @api.depends('expense_line_ids.currency_id', 'company_currency_id') def _compute_currency_id(self): for sheet in self: - # Deal with a display bug when there is a company currency change after creation of the expense sheet - if not sheet.currency_id or sheet.state not in {'post', 'done', 'cancel'}: - sheet.currency_id = sheet.company_id.currency_id + 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): @@ -1041,6 +1133,11 @@ class HrExpenseSheet(models.Model): 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('employee_id', 'employee_id.department_id') def _compute_from_employee_id(self): for sheet in self: @@ -1146,14 +1243,14 @@ class HrExpenseSheet(models.Model): 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') - elif 'state' in init_values and self.state == 'cancel': + if 'state' in init_values and self.state == 'cancel': return self.env.ref('hr_expense.mt_expense_refused') - elif 'state' in init_values and self.state == 'done': + if 'state' in init_values and self.state == 'done': return self.env.ref('hr_expense.mt_expense_paid') - return super(HrExpenseSheet, self)._track_subtype(init_values) + return super()._track_subtype(init_values) def _message_auto_subscribe_followers(self, updated_values, subtype_ids): - res = super(HrExpenseSheet, self)._message_auto_subscribe_followers(updated_values, subtype_ids) + 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: @@ -1215,7 +1312,7 @@ class HrExpenseSheet(models.Model): ''' Open the account.payment.register wizard to pay the selected journal entries. :return: An action opening the account.payment.register wizard. ''' - return self.account_move_id.with_context( + return self.account_move_ids.with_context( default_partner_bank_id=self.employee_id.sudo().bank_account_id.id ).action_register_payment() @@ -1230,16 +1327,31 @@ class HrExpenseSheet(models.Model): 'domain': [('id', 'in', self.expense_line_ids.ids)], } - def action_open_account_move(self): + def action_open_account_moves(self): self.ensure_one() - return { - 'name': self.account_move_id.name, - 'type': 'ir.actions.act_window', - 'view_mode': 'form', - 'views': [[False, "form"]], - 'res_model': 'account.move' if self.payment_mode == 'own_account' else 'account.payment', - 'res_id': self.account_move_id.id if self.payment_mode == 'own_account' else self.account_move_id.payment_id.id, - } + 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 @@ -1315,125 +1427,45 @@ class HrExpenseSheet(models.Model): 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_bill_vals() for sheet in own_account_sheets]) - payments = self.env['account.payment'].with_context(**skip_context).create([sheet._prepare_payment_vals() for sheet in company_account_sheets]) - moves |= payments.move_id - moves.with_context(**skip_context).action_post() + 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]) + payment_moves = payments.move_id + moves |= payment_moves + moves.action_post() self.activity_update() - for sheet in self.filtered(lambda s: not s.accounting_date): - sheet.accounting_date = sheet.account_move_id.date + for sheet in self.filtered(lambda sheet: not sheet.accounting_date): + sheet.accounting_date = sheet.account_move_ids[:1].date if sheet.account_move_ids else False return {move.expense_sheet_id.id: move for move in moves} def _do_reverse_moves(self): - draft_moves = self.account_move_id.filtered(lambda _move: _move.state == 'draft') + draft_moves = self.account_move_ids.filtered(lambda _move: _move.state == 'draft') draft_moves.unlink() - moves = self.account_move_id - draft_moves + 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_payment_vals(self): - self.ensure_one() - payment_method_line = self.env['account.payment.method.line'].search( - [('payment_type', '=', 'outbound'), - ('journal_id', '=', self.journal_id.id), - ('code', '=', 'manual'), - ('company_id', '=', self.company_id.id)], limit=1) - if not payment_method_line: - raise UserError(_("You need to add a manual payment method on the journal (%s)", self.journal_id.name)) - - if not self.expense_line_ids or self.is_multiple_currency: - currency = self.company_id.currency_id - amount = self.total_amount - else: - currency = self.expense_line_ids[0].currency_id - amount = sum(self.expense_line_ids.mapped('total_amount')) - rate = amount / self.total_amount if self.total_amount else 0.0 - move_lines = [] - for expense in self.expense_line_ids: - # Due to rounding and conversion mismatch between vendor bills and payments, we have to force the computation into company account - amount_currency_diff = expense.total_amount_company if currency == expense.company_currency_id else expense.total_amount - last_expense_line = None # Used for rounding in currency issues - tax_data = self.env['account.tax']._compute_taxes([expense._convert_to_tax_base_line_dict()]) - for base_line_data, update in tax_data['base_lines_to_update']: # Add base lines - base_line_data.update(update) - amount_currency = currency.round(base_line_data['price_subtotal'] * rate) - expense_name = expense.name.split("\n")[0][:64] - last_expense_line = base_move_line = { - 'name': f'{expense.employee_id.name}: {expense_name}', - 'account_id':base_line_data['account'].id, - 'product_id': base_line_data['product'].id, - 'analytic_distribution': base_line_data['analytic_distribution'], - 'expense_id': expense.id, - 'tax_ids': [Command.set(expense.tax_ids.ids)], - 'tax_tag_ids': base_line_data['tax_tag_ids'], - 'balance': base_line_data['price_subtotal'], - 'amount_currency': amount_currency, - 'currency_id': currency.id, - } - amount_currency_diff -= amount_currency - move_lines.append(base_move_line) - for tax_line_data in tax_data['tax_lines_to_add']: # Add tax lines - amount_currency = currency.round(tax_line_data['tax_amount'] * rate) - last_expense_line = tax_line = { - 'name': self.env['account.tax'].browse(tax_line_data['tax_id']).name, - 'display_type': 'tax', - 'account_id': tax_line_data['account_id'], - 'analytic_distribution': tax_line_data['analytic_distribution'], - 'expense_id': expense.id, - 'tax_tag_ids': tax_line_data['tax_tag_ids'], - 'balance': tax_line_data['tax_amount'], - 'amount_currency': amount_currency, - 'currency_id': currency.id, - 'tax_repartition_line_id': tax_line_data['tax_repartition_line_id'], - } - move_lines.append(tax_line) - amount_currency_diff -= amount_currency - if not currency.is_zero(amount_currency_diff) and last_expense_line: - last_expense_line['amount_currency'] += amount_currency_diff - expense_name = self.name.split("\n")[0][:64] - move_lines.append({ # Add outstanding payment line - 'name': f'{self.employee_id.name}: {expense_name}', - 'display_type': 'payment_term', - 'account_id': self._get_expense_account_destination(), - 'balance': -self.total_amount, - 'amount_currency': currency.round(-amount), - 'currency_id': currency.id, - }) - return { - **self._prepare_move_vals(), - 'journal_id': self.journal_id.id, - 'move_type': 'entry', - 'amount': amount, - 'payment_type': 'outbound', - 'partner_type': 'supplier', - 'payment_method_line_id': payment_method_line.id, - 'currency_id': currency.id, - 'line_ids': [Command.create(line) for line in move_lines], - } - - def _prepare_bill_vals(self): + def _prepare_bills_vals(self): self.ensure_one() return { **self._prepare_move_vals(), - # 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. + '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_line_vals()) for expense in self.expense_line_ids], + 'line_ids':[Command.create(expense._prepare_move_lines_vals()) for expense in self.expense_line_ids], } 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': '/', - 'journal_id': self.journal_id.id, 'date': self.accounting_date or fields.Date.context_today(self), - 'invoice_date': self.accounting_date or fields.Date.context_today(self), # expense payment behave as bills - 'ref': self.name, - 'expense_sheet_id': [Command.set(self.ids)], + 'expense_sheet_id': self.id, } def _validate_analytic_distribution(self): @@ -1447,9 +1479,9 @@ class HrExpenseSheet(models.Model): def _get_responsible_for_approval(self): if self.user_id: return self.user_id - elif self.employee_id.parent_id.user_id: + if self.employee_id.parent_id.user_id: return self.employee_id.parent_id.user_id - elif self.employee_id.department_id.manager_id.user_id: + 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'] diff --git a/addons/hr_expense/tests/test_expenses.py b/addons/hr_expense/tests/test_expenses.py index 87e1bb9c134..61d3cd89989 100644 --- a/addons/hr_expense/tests/test_expenses.py +++ b/addons/hr_expense/tests/test_expenses.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, Form @@ -52,7 +51,7 @@ class TestExpenses(TestExpenseCommon): ''' Test expense sheet paid by employee flow''' def get_payment(expense_sheet, amount): - ctx = {'active_model': 'account.move', 'active_ids': expense_sheet.account_move_id.ids} + 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, @@ -81,7 +80,7 @@ class TestExpenses(TestExpenseCommon): expense_sheet.action_sheet_move_create() self.assertEqual(expense_sheet.state, 'post', 'sheet should be posted') - move = expense_sheet.account_move_id + move = expense_sheet.account_move_ids self.assertRecordValues(move.line_ids, [ {'debit': 304.35, 'credit': 0.0, 'reconciled': False, }, @@ -90,10 +89,10 @@ class TestExpenses(TestExpenseCommon): ]) expense_sheet.action_reset_expense_sheets() - self.assertEqual(expense_sheet.state, 'draft', 'sheet should be reverted to approved') + self.assertEqual(expense_sheet.state, 'draft', 'sheet should be reverted to draft') reverse_move = self.env['account.move'].search([('reversed_entry_id', '=', move.id)]) - self.assertFalse(expense_sheet.account_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') @@ -109,7 +108,7 @@ class TestExpenses(TestExpenseCommon): payment_2 = get_payment(expense_sheet, 250.0) liquidity_lines2 = payment_2._seek_for_lines()[0] - in_payment_state = expense_sheet.account_move_id._get_invoice_in_payment_state() + 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') @@ -157,19 +156,13 @@ class TestExpenses(TestExpenseCommon): expense_sheet.action_approve_expense_sheets() expense_sheet.action_sheet_move_create() - move = expense_sheet.account_move_id + move = expense_sheet.account_move_ids 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') - move.payment_id.action_draft() - move.payment_id.unlink() - - self.assertEqual(expense_sheet.state, 'approve', 'sheet should be reverted to approve') - self.assertEqual(expense_sheet.payment_state, 'not_paid', 'payment_state should be not_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. @@ -214,7 +207,7 @@ class TestExpenses(TestExpenseCommon): expense_sheet.action_sheet_move_create() # Check expense sheet journal entry values. - self.assertRecordValues(expense_sheet.account_move_id.line_ids.sorted('balance'), [ + self.assertRecordValues(expense_sheet.account_move_ids.line_ids.sorted('balance'), [ # Receivable line (company currency): { 'debit': 0.0, @@ -273,7 +266,7 @@ class TestExpenses(TestExpenseCommon): ]) # Check expense analytic lines. - self.assertRecordValues(expense_sheet.account_move_id.line_ids.analytic_line_ids.sorted('amount'), [ + self.assertRecordValues(expense_sheet.account_move_ids.line_ids.analytic_line_ids.sorted('amount'), [ { 'amount': -869.57, 'date': fields.Date.from_string('2017-01-01'), @@ -355,11 +348,11 @@ class TestExpenses(TestExpenseCommon): 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_id.line_ids.analytic_line_ids + 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_id, [ + self.assertInvoiceValues(expense.account_move_ids, [ { 'balance': 318.18, # 700 * 1:2 (rate) / 1.1 (incl. tax) 'amount_currency': 318.18, @@ -402,11 +395,12 @@ class TestExpenses(TestExpenseCommon): }) foreign_bank_journal = self.company_data['default_journal_bank'].copy() - foreign_bank_journal.currency_id = self.currency_data['currency'].id 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.default_account_id = foreign_bank_journal_account.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, @@ -418,10 +412,10 @@ class TestExpenses(TestExpenseCommon): expense_sheet.action_submit_sheet() expense_sheet.action_approve_expense_sheets() expense_sheet.action_sheet_move_create() - self.assertRecordValues(expense_sheet.account_move_id.payment_id, [{ + self.assertRecordValues(expense_sheet.account_move_ids.payment_id, [{ 'currency_id': self.currency_data['currency'].id, }]) - self.assertRecordValues(expense_sheet.account_move_id.line_ids, [ + 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}, @@ -453,10 +447,10 @@ class TestExpenses(TestExpenseCommon): expense_sheet.action_submit_sheet() expense_sheet.action_approve_expense_sheets() expense_sheet.action_sheet_move_create() - self.assertRecordValues(expense_sheet.account_move_id, [{ + self.assertRecordValues(expense_sheet.account_move_ids, [{ 'currency_id': expense_sheet.company_id.currency_id.id, }]) - self.assertRecordValues(expense_sheet.account_move_id.line_ids, [ + 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}, @@ -554,7 +548,7 @@ class TestExpenses(TestExpenseCommon): })], }) sheets = expense_sheet_own_1_tax + expense_sheet_own_2_tax + expense_sheet_company_1_tax + expense_sheet_company_2_tax - self.assertRecordValues(sheets.mapped('expense_line_ids'), [ + self.assertRecordValues(sheets.expense_line_ids, [ {'untaxed_amount': 869.57, 'total_amount': 1000, 'total_amount_company': 1528.90, 'amount_tax': 130.43, 'amount_tax_company': 199.42}, {'untaxed_amount': 769.23, 'total_amount': 1000, 'total_amount_company': 1528.90, 'amount_tax': 230.77, 'amount_tax_company': 352.82}, {'untaxed_amount': 869.57, 'total_amount': 1000, 'total_amount_company': 1528.90, 'amount_tax': 130.43, 'amount_tax_company': 199.42}, @@ -564,26 +558,26 @@ class TestExpenses(TestExpenseCommon): sheets.action_submit_sheet() sheets.action_approve_expense_sheets() sheets.action_sheet_move_create() - self.assertRecordValues(expense_sheet_own_1_tax.account_move_id.mapped('line_ids'), [ + self.assertRecordValues(expense_sheet_own_1_tax.account_move_ids.line_ids, [ {'balance': 1329.48, 'amount_currency': 1329.48, 'currency_id': self.company_data['currency'].id}, {'balance': 199.42, 'amount_currency': 199.42, 'currency_id': self.company_data['currency'].id}, {'balance': -1528.90, 'amount_currency': -1528.90, 'currency_id': self.company_data['currency'].id}, ]) - self.assertRecordValues(expense_sheet_own_2_tax.account_move_id.mapped('line_ids'), [ + self.assertRecordValues(expense_sheet_own_2_tax.account_move_ids.line_ids, [ {'balance': 1176.08, 'amount_currency': 1176.08, 'currency_id': self.company_data['currency'].id}, {'balance': 176.41, 'amount_currency': 176.41, 'currency_id': self.company_data['currency'].id}, {'balance': 176.41, 'amount_currency': 176.41, 'currency_id': self.company_data['currency'].id}, # == 352.82 amount_tax_company {'balance': -1528.90, 'amount_currency': -1528.90, 'currency_id': self.company_data['currency'].id}, ]) - self.assertRecordValues(expense_sheet_company_1_tax.account_move_id.mapped('line_ids'), [ + self.assertRecordValues(expense_sheet_company_1_tax.account_move_ids.line_ids, [ {'balance': 1329.48, 'amount_currency': 869.57, 'currency_id': foreign_currency.id}, {'balance': 199.42, 'amount_currency': 130.43, 'currency_id': foreign_currency.id}, {'balance': -1528.90, 'amount_currency': -1000.00, 'currency_id': foreign_currency.id}, ]) - self.assertRecordValues(expense_sheet_company_2_tax.account_move_id.mapped('line_ids'), [ + self.assertRecordValues(expense_sheet_company_2_tax.account_move_ids.line_ids, [ {'balance': 1176.08, 'amount_currency': 769.23, 'currency_id': foreign_currency.id}, {'balance': 176.41, 'amount_currency': 115.38, 'currency_id': foreign_currency.id}, # == 352.82 amount_tax_company & 230.77 amount_tax {'balance': 176.41, 'amount_currency': 115.39, 'currency_id': foreign_currency.id}, # One cent more in currency due to rounding @@ -603,8 +597,8 @@ class TestExpenses(TestExpenseCommon): 'accounting_date': '2020-01-01' }) - for i in range(2): - expense_line = self.env['hr.expense'].create({ + for _i in range(2): + self.env['hr.expense'].create({ 'name': 'Car Travel Expenses', 'employee_id': self.expense_employee.id, 'product_id': self.product_a.id, @@ -692,7 +686,6 @@ class TestExpenses(TestExpenseCommon): ], }) - #actions sheet.action_submit_sheet() sheet.action_approve_expense_sheets() @@ -776,7 +769,7 @@ class TestExpenses(TestExpenseCommon): expense_sheet.action_sheet_move_create() # Check expense sheet journal entry values. - self.assertRecordValues(expense_sheet.account_move_id.line_ids.sorted('balance'), [ + self.assertRecordValues(expense_sheet.account_move_ids.line_ids.sorted('balance'), [ # Receivable lines: { 'balance': -345.0, # 115 + 230 @@ -836,7 +829,7 @@ class TestExpenses(TestExpenseCommon): expense_sheet.action_sheet_move_create() # Check whether employee is set as supplier on the receipt - self.assertRecordValues(expense_sheet.account_move_id, [{ + self.assertRecordValues(expense_sheet.account_move_ids, [{ 'partner_id': self.expense_user_employee.partner_id.id, }]) @@ -1052,9 +1045,9 @@ class TestExpenses(TestExpenseCommon): expense_sheet.action_submit_sheet() expense_sheet.action_approve_expense_sheets() expense_sheet.action_sheet_move_create() - move = expense_sheet.account_move_id + moves = expense_sheet.account_move_ids expected_date = fields.Date.from_string('2021-01-31') - self.assertEqual(move.invoice_date_due, expected_date, 'Bill due date should follow employee payment terms') + self.assertSequenceEqual(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 """ @@ -1095,3 +1088,319 @@ class TestExpenses(TestExpenseCommon): 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_a.id, + 'unit_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_a.id, + 'unit_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_a.id, + 'unit_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_a.id, + 'unit_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_a.id, + 'unit_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_a.id, + 'unit_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_a.id, + 'unit_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_a.id, + 'unit_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_a.id, + 'unit_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_a.id, + 'unit_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_a.id, + 'unit_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_a.id, + 'unit_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.48, 'amount_currency': 869.57, 'currency_id': foreign_currency.id}, + {'balance': 199.42, 'amount_currency': 130.43, 'currency_id': foreign_currency.id}, + {'balance': -1528.90, 'amount_currency': -1000.00, 'currency_id': foreign_currency.id}, + ]) diff --git a/addons/hr_expense/views/account_move_views.xml b/addons/hr_expense/views/account_move_views.xml index e96c4ecd08d..d5e897ba26c 100644 --- a/addons/hr_expense/views/account_move_views.xml +++ b/addons/hr_expense/views/account_move_views.xml @@ -12,7 +12,7 @@ class="oe_stat_button" icon="fa-file-text-o" type="object" - attrs="{'invisible': [('expense_sheet_id', '=', [])]}"> + attrs="{'invisible': [('expense_sheet_id', '=', False)]}">
Expense Report
diff --git a/addons/hr_expense/views/account_payment_views.xml b/addons/hr_expense/views/account_payment_views.xml index 7247684e423..a2122567cd8 100644 --- a/addons/hr_expense/views/account_payment_views.xml +++ b/addons/hr_expense/views/account_payment_views.xml @@ -12,7 +12,7 @@ class="oe_stat_button" icon="fa-file-text-o" type="object" - attrs="{'invisible': [('expense_sheet_id', '=', [])]}"> + attrs="{'invisible': [('expense_sheet_id', '=', False)]}">
Expense Report
diff --git a/addons/hr_expense/views/hr_expense_views.xml b/addons/hr_expense/views/hr_expense_views.xml index e1ac005e4dd..9917a285311 100644 --- a/addons/hr_expense/views/hr_expense_views.xml +++ b/addons/hr_expense/views/hr_expense_views.xml @@ -621,6 +621,7 @@ + @@ -662,10 +663,11 @@ - + +
@@ -773,7 +780,7 @@ widget="many2many" mode="tree,kanban" domain="[ - ('state', '=', 'reported'), + ('sheet_id', '=', False), ('employee_id', '=', employee_id), ('company_id', '=', company_id), ('payment_mode', '=?', payment_mode), diff --git a/addons/hr_expense/wizard/account_payment_register.py b/addons/hr_expense/wizard/account_payment_register.py index 2e05df37ea5..315fb964839 100644 --- a/addons/hr_expense/wizard/account_payment_register.py +++ b/addons/hr_expense/wizard/account_payment_register.py @@ -14,9 +14,11 @@ class AccountPaymentRegister(models.TransientModel): def _get_line_batch_key(self, line): # OVERRIDE to set the bank account defined on the employee res = super()._get_line_batch_key(line) - expense_sheet = self.env['hr.expense.sheet'].search([('payment_mode', '=', 'own_account'), ('account_move_id', 'in', line.move_id.ids)]) + expense_sheet = line.move_id.expense_sheet_id.filtered(lambda sheet: sheet and sheet.payment_mode == 'own_account') if expense_sheet and not line.move_id.partner_bank_id: - res['partner_bank_id'] = expense_sheet.employee_id.sudo().bank_account_id.id or line.partner_id.bank_ids and line.partner_id.bank_ids.ids[0] + res['partner_bank_id'] = expense_sheet.employee_id.sudo().bank_account_id.id \ + or line.partner_id.bank_ids \ + and line.partner_id.bank_ids.ids[0] return res def _init_payments(self, to_process, edit_mode=False):