[IMP] hr_expense: do not create purchase.receipt

Rework of the hr.expense workflow so that:
- expenses paid by employee generate purchase.order
- expenses paid by company generate entry that look like payments

Main reason being that purchase.receipt are not active by default.
That makes the entry hard to find, holes in sequences, inconsitency
with payment states.

It also remove the refusal of expenses as this was dead / inaccessible code, only reports can be refused.

task-id: 3126550
[community](https://github.com/odoo/odoo/pull/110518)
[enterprise](https://github.com/odoo/enterprise/pull/36090)

closes odoo/odoo#110518

Related: odoo/upgrade#4266
Related: odoo/enterprise#36090
Signed-off-by: Laurent Smet <las@odoo.com>
This commit is contained in:
det
2023-02-24 10:06:06 +01:00
parent 3a177c448d
commit f79ff3fb37
36 changed files with 699 additions and 492 deletions
+1
View File
@@ -39,6 +39,7 @@ You could use this simplified accounting in case you work with an (external) acc
'views/account_tax_views.xml',
'views/account_full_reconcile_views.xml',
'views/account_payment_term_views.xml',
'views/account_payment_method.xml',
'views/res_partner_bank_views.xml',
'views/report_statement.xml',
'views/terms_template.xml',
+4 -1
View File
@@ -2092,7 +2092,7 @@ class AccountMove(models.Model):
# Only invoice-like and journal entries in "auto tax mode" are synced
tax_container['records'] = container['records'].filtered(lambda m: (m.is_invoice(True) or m.line_ids.tax_ids and not m.tax_cash_basis_origin_move_id))
invoice_container['records'] = container['records'].filtered(lambda m: m.is_invoice(True))
misc_container['records'] = container['records'].filtered(lambda m: m.move_type == 'entry' and not m.tax_cash_basis_origin_move_id)
misc_container['records'] = container['records'].filtered(lambda m: m.is_entry() and not m.tax_cash_basis_origin_move_id)
tax_container, invoice_container, misc_container = ({} for __ in range(3))
update_containers()
@@ -3672,6 +3672,9 @@ class AccountMove(models.Model):
def is_invoice(self, include_receipts=False):
return self.is_sale_document(include_receipts) or self.is_purchase_document(include_receipts)
def is_entry(self):
return self.move_type == 'entry'
@api.model
def get_sale_types(self, include_receipts=False):
return ['out_invoice', 'out_refund'] + (include_receipts and ['out_receipt'] or [])
@@ -119,6 +119,11 @@ class AccountPaymentMethodLine(models.Model):
company_id = fields.Many2one(related='journal_id.company_id')
available_payment_method_ids = fields.Many2many(related='journal_id.available_payment_method_ids')
def name_get(self):
if self.env.context.get('show_payment_journal_id'):
return [(method.id, "%s (%s)" % (method.name, method.journal_id.name)) for method in self]
return super().name_get()
@api.depends('payment_method_id.name')
def _compute_name(self):
for method in self:
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_account_payment_method_line_tree" model="ir.ui.view">
<field name="name">account.payment.method.line.tree</field>
<field name="model">account.payment.method.line</field>
<field name="arch" type="xml">
<tree edit="0">
<field name="name" string="Payment Method Name"/>
<field name="journal_id"/>
</tree>
</field>
</record>
</odoo>
@@ -227,7 +227,9 @@
</button>
<!-- Journal Entry button -->
<button name="button_open_journal_entry" type="object" class="oe_stat_button" icon="fa-bars">
<button name="button_open_journal_entry" type="object"
class="oe_stat_button" icon="fa-bars"
groups="account.group_account_user,account.group_account_readonly">
<div class="o_stat_info">
<span class="o_stat_text">Journal Entry</span>
</div>
+1
View File
@@ -45,6 +45,7 @@ This module also uses analytic accounting and is compatible with the invoice on
'security/ir_rule.xml',
'report/hr_expense_report.xml',
'views/account_move_views.xml',
'views/account_payment_views.xml',
'views/hr_department_views.xml',
'views/res_config_settings_views.xml',
'views/account_journal_dashboard.xml',
@@ -20,5 +20,17 @@
<field name="description">Expense report paid</field>
<field name="default" eval="True"/>
</record>
<record id="mt_expense_reset" model="mail.message.subtype">
<field name="name">Draft</field>
<field name="res_model">hr.expense.sheet</field>
<field name="default" eval="True"/>
<field name="description">Expense report reset to Draft</field>
</record>
<record id="mt_expense_entry_delete" model="mail.message.subtype">
<field name="name">Journal Entry Deleted</field>
<field name="res_model">hr.expense.sheet</field>
<field name="default" eval="True"/>
<field name="description">Journal entry deleted</field>
</record>
</data>
</odoo>
+1 -1
View File
@@ -2,7 +2,7 @@
<odoo>
<data noupdate="1">
<template id="hr_expense_template_refuse_reason">
<p>Your Expense <t t-if="is_sheet">Report </t><t t-esc="name"/> has been refused</p>
<p>Your Expense Report <t t-esc="name"/> has been refused</p>
<ul class="o_timeline_tracking_value_list">
<li>Reason : <t t-esc="reason"/></li>
</ul>
+1
View File
@@ -4,6 +4,7 @@
from . import hr_employee
from . import account_move
from . import account_move_line
from . import account_payment
from . import hr_department
from . import hr_expense
from . import product_template
+25 -28
View File
@@ -28,45 +28,42 @@ class AccountMove(models.Model):
def is_purchase_document(self, include_receipts=False):
return bool(self.expense_sheet_id and include_receipts) 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()
def _creation_message(self):
if self.line_ids.expense_id:
return _("Expense entry Created")
if self.expense_sheet_id:
return _("Expense entry created from: %s", self.expense_sheet_id._get_html_link())
return super()._creation_message()
@api.depends('expense_sheet_id.payment_mode')
def _compute_payment_state(self):
company_paid = self.filtered(lambda m: m.expense_sheet_id.payment_mode == 'company_account')
for move in company_paid:
move.payment_state = move._get_invoice_in_payment_state()
super(AccountMove, self - company_paid)._compute_payment_state()
@api.depends('expense_sheet_id')
def _compute_needed_terms(self):
# EXTENDS account
# The needed terms need to be computed for journal entries, depending on the expense and the currency
# since one expense sheet can contain multiple currencies.
super()._compute_needed_terms()
for move in self:
if move.expense_sheet_id:
move.needed_terms = {}
agg = defaultdict(lambda: {'company': 0.0, 'foreign': 0.0})
for line in move.line_ids:
if line.display_type != 'payment_term':
agg[line.expense_id]['company'] += line.balance
agg[line.expense_id]['foreign'] += line.amount_currency
for expense in move.line_ids.expense_id:
move.needed_terms[frozendict({
'move_id': move.id,
'date_maturity': expense.sheet_id.accounting_date or expense.date or fields.Date.context_today(expense),
'expense_id': expense.id,
})] = {
'balance': -agg[expense]['company'],
'amount_currency': -agg[expense]['foreign'],
'name': '',
'currency_id': expense.currency_id.id,
'account_id': expense._get_expense_account_destination(),
move.needed_terms = {
frozendict(
{
"move_id": move.id,
"date_maturity": move.expense_sheet_id.accounting_date
or fields.Date.context_today(move.expense_sheet_id),
}
): {
"balance": -sum(move.line_ids.mapped("balance")),
"name": "",
"account_id": move.expense_sheet_id._get_expense_account_destination(),
}
}
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
return super()._reverse_moves(default_values_list=default_values_list, cancel=cancel)
+3 -14
View File
@@ -8,22 +8,11 @@ from odoo.tools.misc import frozendict
class AccountMoveLine(models.Model):
_inherit = "account.move.line"
expense_id = fields.Many2one('hr.expense', string='Expense', copy=False)
expense_id = fields.Many2one('hr.expense', string='Expense', copy=True) # copy=True, else we don't know price is tax incl.
@api.constrains('account_id', 'display_type')
def _check_payable_receivable(self):
super(AccountMoveLine, self.filtered(lambda line: not line.expense_id or line.expense_id.payment_mode != 'company_account'))._check_payable_receivable()
def reconcile(self):
# OVERRIDE
not_paid_expenses = self.expense_id.filtered(lambda expense: expense.state != 'done')
res = super().reconcile()
# Do not update expense or expense sheet states when reversing journal entries
not_paid_expense_sheets = not_paid_expenses.sheet_id.filtered(lambda sheet: sheet.account_move_id.payment_state != 'reversed')
paid_expenses = not_paid_expenses.filtered(lambda expense: expense.currency_id.is_zero(expense.amount_residual))
paid_expenses.write({'state': 'done'})
not_paid_expense_sheets.filtered(lambda sheet: all(expense.state == 'done' for expense in sheet.expense_line_ids)).set_to_paid()
return res
super(AccountMoveLine, self.filtered(lambda line: line.move_id.expense_sheet_id.payment_mode != 'company_account'))._check_payable_receivable()
def _get_attachment_domains(self):
attachment_domains = super(AccountMoveLine, self)._get_attachment_domains()
@@ -59,7 +48,7 @@ class AccountMoveLine(models.Model):
def _convert_to_tax_base_line_dict(self):
result = super()._convert_to_tax_base_line_dict()
if self.move_id.expense_sheet_id:
if self.expense_id:
result.setdefault('extra_context', {})
result['extra_context']['force_price_include'] = True
return result
@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, _
from odoo.exceptions import UserError
class AccountPayment(models.Model):
_inherit = "account.payment"
def action_open_expense_report(self):
self.ensure_one()
return {
'name': self.expense_sheet_id.name,
'type': 'ir.actions.act_window',
'view_type': 'form',
'view_mode': 'form',
'views': [(False, 'form')],
'res_model': 'hr.expense.sheet',
'res_id': self.expense_sheet_id.id
}
def _synchronize_from_moves(self, changed_fields):
if self.expense_sheet_id:
# Constraints bypass when entry is linked to an expense.
# 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)
def _synchronize_to_moves(self, changed_fields):
if self.expense_sheet_id:
raise UserError(_("You cannot do this modification since the payment is linked to an expense report."))
return super()._synchronize_to_moves(changed_fields)
def _creation_message(self):
self.ensure_one()
if self.move_id.expense_sheet_id:
return _("Payment created for: %s", self.move_id.expense_sheet_id._get_html_link())
return super()._creation_message()
+351 -303
View File
@@ -8,8 +8,8 @@ import werkzeug
from odoo import api, fields, Command, models, _
from odoo.tools import float_round
from odoo.exceptions import UserError, ValidationError
from odoo.tools import email_split, float_is_zero, float_repr, float_compare, is_html_empty
from odoo.tools.misc import clean_context, format_date
from odoo.tools import email_split, float_repr, float_compare, is_html_empty
class HrExpense(models.Model):
@@ -100,7 +100,6 @@ class HrExpense(models.Model):
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")
is_refused = fields.Boolean("Explicitly Refused by manager or accountant", readonly=True, copy=False)
is_editable = fields.Boolean("Is Editable By Current User", compute='_compute_is_editable')
is_ref_editable = fields.Boolean("Reference Is Editable By Current User", compute='_compute_is_ref_editable')
@@ -571,79 +570,21 @@ class HrExpense(models.Model):
# Business
# ----------------------------------------
def _get_expense_account_destination(self):
def _prepare_move_line_vals(self):
self.ensure_one()
account_dest = self.env['account.account']
if self.payment_mode == 'company_account':
journal = self.sheet_id.bank_journal_id
account_dest = (
journal.outbound_payment_method_line_ids[0].payment_account_id
or journal.company_id.account_journal_payment_credit_account_id
)
else:
if not self.employee_id.sudo().address_home_id:
raise UserError(_("No Home Address found for the employee %s, please configure one.") % (self.employee_id.name))
partner = self.employee_id.sudo().address_home_id.with_company(self.company_id)
account_dest = partner.property_account_payable_id or partner.parent_id.property_account_payable_id
return account_dest.id
def action_move_create(self):
'''
main function that is called when trying to create the accounting entries related to an expense
'''
moves = self.env['account.move'].create([
{
'journal_id': (
sheet.bank_journal_id
if sheet.payment_mode == 'company_account' else
sheet.journal_id
).id,
'move_type': 'in_receipt',
'company_id': sheet.company_id.id,
'partner_id': sheet.employee_id.sudo().address_home_id.commercial_partner_id.id,
'date': sheet.accounting_date or fields.Date.context_today(sheet),
'invoice_date': sheet.accounting_date or fields.Date.context_today(sheet),
'ref': sheet.name,
# force the name to the default value, to avoid an eventual 'default_name' in the context
# to set it to '' which cause no number to be given to the account.move when posted.
'name': '/',
'expense_sheet_id': [Command.set(sheet.ids)],
'line_ids':[
Command.create({
'name': expense.employee_id.name + ': ' + expense.name.split('\n')[0][:64],
'account_id': expense.account_id.id,
'quantity': expense.quantity or 1,
'price_unit': expense.unit_amount if expense.unit_amount != 0 else expense.total_amount,
'product_id': expense.product_id.id,
'product_uom_id': expense.product_uom_id.id,
'analytic_distribution': expense.analytic_distribution,
'expense_id': expense.id,
'partner_id': expense.employee_id.sudo().address_home_id.commercial_partner_id.id,
'tax_ids': [(6, 0, expense.tax_ids.ids)],
'currency_id': expense.currency_id.id,
})
for expense in sheet.expense_line_ids
]
}
for sheet in self.sheet_id
])
moves._post()
for expense in self:
if expense.payment_mode == 'company_account':
expense.sheet_id.paid_expense_sheets()
return {move.expense_sheet_id.id: move for move in moves}
def refuse_expense(self, reason):
self.write({'is_refused': True})
self.sheet_id.write({'state': 'cancel'})
for expense in self:
expense.sheet_id.message_post_with_source(
'hr_expense.hr_expense_template_refuse_reason',
render_values={'reason': reason, 'is_sheet': False, 'name': expense.name},
subtype_xmlid='mail.mt_comment',
)
return {
'name': self.employee_id.name + ': ' + self.name.split('\n')[0][:64],
'account_id': self.account_id.id,
'quantity': self.quantity or 1,
'price_unit': self.unit_amount if self.unit_amount != 0 else self.total_amount,
'product_id': self.product_id.id,
'product_uom_id': self.product_uom_id.id,
'analytic_distribution': self.analytic_distribution,
'expense_id': self.id,
'partner_id': False if self.payment_mode == 'company_account' else self.employee_id.sudo().address_home_id.commercial_partner_id.id,
'tax_ids': [Command.set(self.tax_ids.ids)],
'currency_id': self.currency_id.id,
}
@api.model
def get_expense_dashboard(self):
@@ -862,19 +803,9 @@ class HrExpenseSheet(models.Model):
journal = self.env['account.journal'].search([('type', '=', 'purchase'), ('company_id', '=', default_company_id)], limit=1)
return journal.id
@api.model
def _default_bank_journal_id(self):
company_journal_id = self.env.company.company_expense_journal_id
if company_journal_id:
return company_journal_id
default_company_id = self.default_get(['company_id'])['company_id']
journal = self.env['account.journal'].search([('type', 'in', ['cash', 'bank']), ('company_id', '=', default_company_id)], limit=1)
return journal
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', compute='_compute_product_ids', search='_search_product_ids', string='Categories')
is_editable = fields.Boolean("Expense Lines Are Editable By Current User", compute='_compute_is_editable')
expense_number = fields.Integer(compute='_compute_expense_number', string='Number of Expenses')
state = fields.Selection([
('draft', 'Draft'),
@@ -883,15 +814,21 @@ class HrExpenseSheet(models.Model):
('post', 'Posted'),
('done', 'Done'),
('cancel', 'Refused')
], string='Status', index=True, readonly=True, tracking=True, copy=False, default='draft', required=True)
payment_state = fields.Selection(
selection=lambda self: self.env["account.move"]._fields["payment_state"].selection,
string="Payment Status",
store=True, readonly=True, copy=False, tracking=True, compute='_compute_payment_state')
employee_id = fields.Many2one('hr.employee', string="Employee", required=True, readonly=True, tracking=True, states={'draft': [('readonly', False)]}, default=_default_employee_id, check_company=True, domain= lambda self: self.env['hr.expense']._get_employee_id_domain())
], 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, readonly=True, states={'draft': [('readonly', False)]}, default=lambda self: self.env.company)
employee_id = fields.Many2one('hr.employee', string="Employee", required=True, readonly=True, tracking=True, states={'draft': [('readonly', False)]}, default=_default_employee_id, check_company=True, domain=lambda self: self.env['hr.expense']._get_employee_id_domain())
address_id = fields.Many2one('res.partner', compute='_compute_from_employee_id', store=True, readonly=False, copy=True, string="Employee Home Address", check_company=True)
payment_mode = fields.Selection(related='expense_line_ids.payment_mode', readonly=True, string="Paid By", tracking=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 Amount', 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)
@@ -899,21 +836,44 @@ class HrExpenseSheet(models.Model):
amount_residual = fields.Monetary(
string="Amount Due", store=True,
currency_field='currency_id',
related='account_move_id.amount_residual')
company_id = fields.Many2one('res.company', string='Company', required=True, readonly=True, states={'draft': [('readonly', False)]}, default=lambda self: self.env.company)
compute='_compute_from_account_move_id')
currency_id = fields.Many2one('res.currency', string='Currency', readonly=True, states={'draft': [('readonly', False)]}, default=lambda self: self.env.company.currency_id)
attachment_number = fields.Integer(compute='_compute_attachment_number', string='Number of Attachments')
journal_id = fields.Many2one('account.journal', string='Expense Journal', states={'done': [('readonly', True)], 'post': [('readonly', True)]}, check_company=True, domain="[('type', '=', 'purchase'), ('company_id', '=', company_id)]",
default=_default_journal_id, help="The journal used when the expense is done.")
bank_journal_id = fields.Many2one('account.journal', string='Bank Journal', states={'done': [('readonly', True)], 'post': [('readonly', True)]}, check_company=True, domain="[('type', 'in', ['cash', 'bank']), ('company_id', '=', company_id)]",
default=_default_bank_journal_id, help="The payment method used when the expense is paid by the company.")
accounting_date = fields.Date("Accounting Date")
account_move_id = fields.Many2one('account.move', string='Journal Entry', ondelete='restrict', copy=False, readonly=True)
department_id = fields.Many2one('hr.department', compute='_compute_from_employee_id', store=True, readonly=False, copy=False, string='Department', states={'post': [('readonly', True)], 'done': [('readonly', True)]})
is_multiple_currency = fields.Boolean("Handle lines with different currencies", compute='_compute_is_multiple_currency')
# === 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_id')
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', states={'done': [('readonly', True)], 'post': [('readonly', True)]}, check_company=True, domain="[('type', '=', 'purchase'), ('company_id', '=', company_id)]",
default=_default_journal_id, help="The journal used when the expense is 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("Accounting Date")
account_move_id = fields.Many2one('account.move', string='Journal Entry', ondelete='set null', copy=False, readonly=True)
journal_id = fields.Many2one('account.journal', compute='_compute_journal_id', string="Expense Journal", store=True)
# === Security fields === #
can_reset = fields.Boolean('Can Reset', compute='_compute_can_reset')
can_approve = fields.Boolean('Can Approve', compute='_compute_can_approve')
approval_date = fields.Datetime('Approval Date', readonly=True)
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'),
@@ -926,14 +886,47 @@ 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')
def _compute_payment_state(self):
@api.depends('account_move_id.payment_state', 'account_move_id.amount_residual')
def _compute_from_account_move_id(self):
for sheet in self:
sheet.payment_state = sheet.account_move_id.payment_state or 'not_paid'
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
def _compute_attachment_number(self):
@api.depends('selectable_payment_method_line_ids')
def _compute_payment_method_line_id(self):
for sheet in self:
sheet.attachment_number = sum(sheet.expense_line_ids.mapped('attachment_number'))
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_id', '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:
sheet.state = 'post'
elif sheet.approval_state:
sheet.state = sheet.approval_state
else:
sheet.state = 'draft'
@api.depends('expense_line_ids.currency_id')
def _compute_is_multiple_currency(self):
@@ -949,10 +942,26 @@ class HrExpenseSheet(models.Model):
@api.depends_context('uid')
@api.depends('employee_id')
def _compute_can_approve(self):
is_approver = self.user_has_groups('hr_expense.group_hr_expense_team_approver, hr_expense.group_hr_expense_user')
is_manager = self.user_has_groups('hr_expense.group_hr_expense_manager')
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:
sheet.can_approve = is_manager or (is_approver and sheet.employee_id.user_id != self.env.user)
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):
@@ -1012,6 +1021,19 @@ class HrExpenseSheet(models.Model):
value = [value]
return [('expense_line_ids.product_id', operator, value)]
# ----------------------------------------
# ORM Overrides
# ----------------------------------------
def read(self, fields=None, load='_classic_read'):
# setting the context in the field on the view is not enough
return super(HrExpenseSheet, self.with_context(show_payment_journal_id=True)).read(fields=fields, load=load)
def onchange(self, values, field_name, field_onchange):
# setting the context in the field on the view is not enough
self_ctx = self.with_context(show_payment_journal_id=True)
return super(HrExpenseSheet, self_ctx).onchange(values, field_name, field_onchange)
@api.model_create_multi
def create(self, vals_list):
context = clean_context(self.env.context)
@@ -1046,7 +1068,11 @@ class HrExpenseSheet(models.Model):
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')
elif 'state' in init_values and self.state == 'cancel':
return self.env.ref('hr_expense.mt_expense_refused')
@@ -1062,190 +1088,6 @@ class HrExpenseSheet(models.Model):
res.append((employee.user_id.partner_id.id, subtype_ids, False))
return res
# --------------------------------------------
# Actions
# --------------------------------------------
def action_sheet_move_create(self):
samples = self.mapped('expense_line_ids.sample')
if samples.count(True):
if samples.count(False):
raise UserError(_("You can't mix sample expenses and regular ones"))
self.write({'state': 'post'})
return
if any(sheet.state != 'approve' for sheet in self):
raise UserError(_("You can only generate accounting entry for approved expense(s)."))
if any(not sheet.journal_id for sheet in self):
raise UserError(_("Specify expense journal to generate accounting entries."))
expense_line_ids = self.mapped('expense_line_ids')\
.filtered(lambda r: not float_is_zero(r.total_amount, precision_rounding=(r.currency_id or self.env.company.currency_id).rounding))
res = expense_line_ids.with_context(clean_context(self.env.context)).action_move_create()
for sheet in self.filtered(lambda s: not s.accounting_date):
sheet.accounting_date = sheet.account_move_id.date
to_post = self.filtered(lambda sheet: sheet.payment_mode == 'own_account' and sheet.expense_line_ids)
to_post.write({'state': 'post'})
(self - to_post).write({'state': 'done'})
self.activity_update()
return res
def action_unpost(self):
self = self.with_context(clean_context(self.env.context))
for sheet in self:
move = sheet.account_move_id
sheet.account_move_id = False
if move.state == 'draft':
move.unlink()
else:
move._reverse_moves(cancel=True)
sheet.write({'state': 'draft'})
def action_get_attachment_view(self):
res = self.env['ir.actions.act_window']._for_xml_id('base.action_attachment')
res['domain'] = [('res_model', '=', 'hr.expense'), ('res_id', 'in', self.expense_line_ids.ids)]
res['context'] = {
'default_res_model': 'hr.expense.sheet',
'default_res_id': self.id,
'create': False,
'edit': False,
}
return res
def action_get_expense_view(self):
self.ensure_one()
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_move(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',
'res_id': self.account_move_id.id
}
# --------------------------------------------
# Business
# --------------------------------------------
def set_to_paid(self):
self.write({'state': 'done'})
def action_submit_sheet(self):
self.write({'state': 'submit'})
self.sudo().activity_update()
def _check_can_approve(self):
if not self.user_has_groups('hr_expense.group_hr_expense_team_approver'):
raise UserError(_("Only Managers and HR Officers can approve expenses"))
elif not self.user_has_groups('hr_expense.group_hr_expense_manager'):
current_managers = self.employee_id.expense_manager_id | self.employee_id.parent_id.user_id | self.employee_id.department_id.manager_id.user_id | self.user_id
if self.employee_id.user_id == self.env.user:
raise UserError(_("You cannot approve your own expenses"))
if not self.env.user in current_managers and not self.user_has_groups('hr_expense.group_hr_expense_user') and self.employee_id.expense_manager_id != self.env.user:
raise UserError(_("You can only approve your department expenses"))
def 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 _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 _do_approve(self):
self._check_can_approve()
notification = {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('There are no expense reports to approve.'),
'type': 'warning',
'sticky': False, #True/False will display for few seconds if false
},
}
filtered_sheet = self.filtered(lambda s: s.state in ['submit', 'draft'])
if not filtered_sheet:
return notification
for sheet in filtered_sheet:
sheet.write({'state': 'approve', 'user_id': sheet.user_id.id or self.env.user.id})
notification['params'].update({
'title': _('The expense reports were successfully approved.'),
'type': 'success',
'next': {'type': 'ir.actions.act_window_close'},
})
self.activity_update()
return notification
def paid_expense_sheets(self):
self.write({'state': 'done'})
def refuse_sheet(self, reason):
if not self.user_has_groups('hr_expense.group_hr_expense_team_approver'):
raise UserError(_("Only Managers and HR Officers can approve expenses"))
elif not self.user_has_groups('hr_expense.group_hr_expense_manager'):
current_managers = self.employee_id.expense_manager_id | self.employee_id.parent_id.user_id | self.employee_id.department_id.manager_id.user_id | self.user_id
if self.employee_id.user_id == self.env.user:
raise UserError(_("You cannot refuse your own expenses"))
if not self.env.user in current_managers and not self.user_has_groups('hr_expense.group_hr_expense_user') and self.employee_id.expense_manager_id != self.env.user:
raise UserError(_("You can only refuse your department expenses"))
self.write({'state': 'cancel'})
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, 'is_sheet': True, 'name': sheet.name},
)
self.activity_update()
def reset_expense_sheets(self):
if not self.can_reset:
raise UserError(_("Only HR Officers or the concerned employee can reset to draft."))
self.mapped('expense_line_ids').write({'is_refused': False})
self.sudo().write({'state': 'draft', 'approval_date': False})
self.activity_update()
return True
def _get_responsible_for_approval(self):
if self.user_id:
return self.user_id
elif self.employee_id.parent_id.user_id:
return self.employee_id.parent_id.user_id
elif self.employee_id.department_id.manager_id.user_id:
return self.employee_id.department_id.manager_id.user_id
return self.env['res.users']
def activity_update(self):
reports_requiring_feedback = self.env['hr.expense.sheet']
reports_activity_unlink = self.env['hr.expense.sheet']
@@ -1263,20 +1105,226 @@ class HrExpenseSheet(models.Model):
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.
:return: An action opening the account.payment.register wizard.
'''
return self.account_move_id.with_context(
default_partner_bank_id=self.employee_id.sudo().bank_account_id.id
).action_register_payment()
def action_open_expense_view(self):
self.ensure_one()
return {
'name': _('Register Payment'),
'res_model': 'account.payment.register',
'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_move(self):
self.ensure_one()
return {
'name': self.account_move_id.name,
'type': 'ir.actions.act_window',
'view_mode': 'form',
'views': [[False, "form"]],
'context': {
'active_model': 'account.move',
'active_ids': self.account_move_id.ids,
'default_partner_bank_id': self.employee_id.sudo().bank_account_id.id,
},
'target': 'new',
'type': 'ir.actions.act_window',
'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,
}
# --------------------------------------------
# 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."))
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_*
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'].create([sheet._prepare_payment_vals() for sheet in company_account_sheets])
moves |= payments.move_id
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
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.unlink()
moves = self.account_move_id - 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()
res = self._prepare_move_vals()
res.update({
'move_type': 'entry',
'amount': self.total_amount,
'payment_type': 'outbound',
'partner_type': 'supplier',
'payment_method_line_id': self.payment_method_line_id.id,
'partner_id': False,
})
return res
def _prepare_bill_vals(self):
self.ensure_one()
res = self._prepare_move_vals()
res.update({
'move_type': 'in_invoice',
'partner_id': self.employee_id.sudo().address_home_id.commercial_partner_id.id,
})
return res
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)],
'line_ids':[
Command.create(expense._prepare_move_line_vals())
for expense in self.expense_line_ids
]
}
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
elif self.employee_id.parent_id.user_id:
return self.employee_id.parent_id.user_id
elif self.employee_id.department_id.manager_id.user_id:
return self.employee_id.department_id.manager_id.user_id
return self.env['res.users']
def _get_expense_account_destination(self):
self.ensure_one()
account_dest = self.env['account.account']
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().address_home_id:
raise UserError(_("No Home Address found for the employee %s, please configure one.") % (self.employee_id.name))
partner = self.employee_id.sudo().address_home_id.with_company(self.company_id)
account_dest = partner.property_account_payable_id or partner.parent_id.property_account_payable_id
return account_dest.id
+4 -5
View File
@@ -13,10 +13,9 @@ class ResCompany(models.Model):
domain="[('type', '=', 'purchase'), ('company_id', '=', company_id)]",
help="The company's default journal used when an employee expense is created.",
)
company_expense_journal_id = fields.Many2one(
"account.journal",
string="Default Company Expense Journal",
company_expense_allowed_payment_method_line_ids = fields.Many2many(
"account.payment.method.line",
string="Payment methods available for expenses paid by company",
check_company=True,
domain="[('type', 'in', ['cash', 'bank']), ('company_id', '=', company_id)]",
help="The company's default journal used when a company expense is created.",
domain="[('payment_type', '=', 'outbound'), ('company_id', '=', company_id),('journal_id', '!=', False)]",
)
@@ -16,7 +16,11 @@ class ResConfigSettings(models.TransientModel):
module_hr_payroll_expense = fields.Boolean(string='Reimburse Expenses in Payslip')
module_hr_expense_extract = fields.Boolean(string='Send bills to OCR to generate expenses')
expense_journal_id = fields.Many2one('account.journal', related='company_id.expense_journal_id', readonly=False)
company_expense_journal_id = fields.Many2one('account.journal', related='company_id.company_expense_journal_id', readonly=False)
company_expense_allowed_payment_method_line_ids = fields.Many2many(
comodel_name='account.payment.method.line',
related='company_id.company_expense_allowed_payment_method_line_ids',
readonly=False,
)
@api.model
def get_values(self):
+11 -9
View File
@@ -54,15 +54,17 @@ export class ExpenseListController extends ListController {
const recordIds = records.map((a) => a.resId);
const model = this.model.rootParams.resModel;
const res = await this.orm.call(model, action, [recordIds]);
await this.actionService.doAction(res, {
additionalContext: {
dont_redirect_to_payments: 1,
},
onClose: async () => {
await this.model.root.load();
this.render(true);
}
});
if (res) {
await this.actionService.doAction(res, {
additionalContext: {
dont_redirect_to_payments: 1,
},
onClose: async () => {
await this.model.root.load();
this.render(true);
}
});
}
// sgv note: we tried this.model.notify(); and does not work
await this.model.root.load();
this.render(true);
+1 -1
View File
@@ -21,7 +21,7 @@
</xpath>
<xpath expr="//button[hasclass('o_button_upload_expense')]" position="after">
<button t-if="displayApprove()" class="d-none d-md-block btn btn-secondary" t-on-click="() => this.onClick('approve_expense_sheets')">
<button t-if="displayApprove()" class="d-none d-md-block btn btn-secondary" t-on-click="() => this.onClick('action_approve_expense_sheets')">
Approve Report
</button>
</xpath>
+102 -52
View File
@@ -48,8 +48,8 @@ class TestExpenses(TestExpenseCommon):
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_payment_state(self):
''' Test expense sheet payment states when partially paid, in payment and paid. '''
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_id.ids}
@@ -73,19 +73,45 @@ class TestExpenses(TestExpenseCommon):
})
expense_sheet.action_submit_sheet()
expense_sheet.approve_expense_sheets()
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_id
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},
])
expense_sheet.action_reset_expense_sheets()
self.assertEqual(expense_sheet.state, 'draft', 'sheet should be reverted to approved')
reverse_move = self.env['account.move'].search([('reversed_entry_id', '=', move.id)])
self.assertFalse(expense_sheet.account_move_id)
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')
expense_sheet.action_submit_sheet()
expense_sheet.action_approve_expense_sheets()
expense_sheet.action_sheet_move_create()
payment = get_payment(expense_sheet, 100.0)
liquidity_lines1 = payment._seek_for_lines()[0]
payment_1 = get_payment(expense_sheet, 100.0)
liquidity_lines1 = payment_1._seek_for_lines()[0]
self.assertEqual(expense_sheet.payment_state, 'partial', 'payment_state should be partial')
payment = get_payment(expense_sheet, 250.0)
liquidity_lines2 = payment._seek_for_lines()[0]
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()
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')
statement_line = self.env['account.bank.statement.line'].create({
'journal_id': self.company_data['default_journal_bank'].id,
@@ -102,6 +128,47 @@ class TestExpenses(TestExpenseCommon):
(st_suspense_lines + liquidity_lines1 + liquidity_lines2).reconcile()
self.assertEqual(expense_sheet.payment_state, 'paid', 'payment_state should be paid')
self.assertEqual(expense_sheet.state, 'done', 'sheet should be marked as done')
payment_1.action_draft()
payment_2.action_draft()
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')
def test_expense_sheet_paid_company(self):
''' Test expense sheet paid by company flow '''
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_a.id,
'unit_amount': 350.00,
'payment_mode': 'company_account',
})]
})
expense_sheet.action_submit_sheet()
expense_sheet.action_approve_expense_sheets()
expense_sheet.action_sheet_move_create()
move = expense_sheet.account_move_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')
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 """
@@ -143,7 +210,7 @@ class TestExpenses(TestExpenseCommon):
self.assertRecordValues(expense_sheet, [{'state': 'draft', 'total_amount': 1500.0}])
expense_sheet.action_submit_sheet()
expense_sheet.approve_expense_sheets()
expense_sheet.action_approve_expense_sheets()
expense_sheet.action_sheet_move_create()
# Check expense sheet journal entry values.
@@ -151,25 +218,14 @@ class TestExpenses(TestExpenseCommon):
# Receivable line (company currency):
{
'debit': 0.0,
'credit': 1000.0,
'amount_currency': -1000.0,
'credit': 1750.0,
'amount_currency': -1750.0,
'account_id': self.company_data['default_account_payable'].id,
'product_id': False,
'currency_id': self.company_data['currency'].id,
'tax_line_id': False,
'analytic_distribution': False,
},
# Receivable line (foreign currency):
{
'debit': 0.0,
'credit': 750,
'amount_currency': -1500.0,
'account_id': self.company_data['default_account_payable'].id,
'product_id': False,
'currency_id': self.currency_data['currency'].id,
'tax_line_id': False,
'analytic_distribution': False,
},
# Tax line (foreign currency):
{
'debit': 97.83,
@@ -248,10 +304,11 @@ class TestExpenses(TestExpenseCommon):
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.approve_expense_sheets()
expense_sheet.action_approve_expense_sheets()
expense_sheet.action_sheet_move_create()
def test_account_entry_multi_currency(self):
@@ -285,7 +342,7 @@ class TestExpenses(TestExpenseCommon):
expense.action_submit_sheet()
self.assertEqual(expense.state, 'submit', 'Expense is not in Reported state')
# Approve
expense.approve_expense_sheets()
expense.action_approve_expense_sheets()
self.assertEqual(expense.state, 'approve', 'Expense is not in Approved state')
# Create Expense Entries
expense.action_sheet_move_create()
@@ -312,7 +369,7 @@ class TestExpenses(TestExpenseCommon):
'analytic_line_ids': [],
}, {
'balance': -350.0,
'amount_currency': -700.0,
'amount_currency': -350.0,
'product_id': False,
'price_unit': 0.0,
'price_subtotal': 0.0,
@@ -347,7 +404,7 @@ class TestExpenses(TestExpenseCommon):
})
expense.action_submit_sheet()
expense.approve_expense_sheets()
expense.action_approve_expense_sheets()
# Assert not "Cannot create unbalanced journal entry" error.
expense.action_sheet_move_create()
@@ -398,14 +455,13 @@ class TestExpenses(TestExpenseCommon):
#actions
sheet.action_submit_sheet()
sheet.approve_expense_sheets()
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()
wizard.group_payment = False
action = wizard.action_create_payments()
self.assertEqual(sheet.state, 'done', 'all account.move.line linked to expenses must be reconciled after payment')
move = self.env['account.payment'].search(action['domain']).move_id
move = self.env['account.payment'].browse(action['res_id']).move_id
move.button_cancel()
self.assertEqual(sheet.state, 'done', 'Sheet state must not change when the payment linked to that sheet is canceled')
@@ -428,7 +484,7 @@ class TestExpenses(TestExpenseCommon):
#actions
sheet.action_submit_sheet()
sheet.approve_expense_sheets()
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()
@@ -505,18 +561,14 @@ class TestExpenses(TestExpenseCommon):
self.assertRecordValues(expense_sheet, [{'state': 'draft', 'total_amount': 345.0}])
expense_sheet.action_submit_sheet()
expense_sheet.approve_expense_sheets()
expense_sheet.action_approve_expense_sheets()
expense_sheet.action_sheet_move_create()
# Check expense sheet journal entry values.
self.assertRecordValues(expense_sheet.account_move_id.line_ids.sorted('balance'), [
# Receivable lines:
{
'balance': -230.0,
'account_id': self.company_data['default_account_payable'].id,
},
{
'balance': -115.0,
'balance': -345.0,
'account_id': self.company_data['default_account_payable'].id,
},
# Tax lines:
@@ -569,7 +621,7 @@ class TestExpenses(TestExpenseCommon):
})
expense_sheet.action_submit_sheet()
expense_sheet.approve_expense_sheets()
expense_sheet.action_approve_expense_sheets()
expense_sheet.action_sheet_move_create()
# Check whether employee is set as supplier on the receipt
@@ -606,31 +658,29 @@ class TestExpenses(TestExpenseCommon):
#actions
sheet.action_submit_sheet()
sheet.approve_expense_sheets()
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
wiz_form.group_payment = False
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')
payments = self.env[action['res_model']].search(action['domain'])
for payment in payments:
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
})
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):
"""
@@ -59,12 +59,12 @@ class TestExpensesAccessRights(TestExpenseCommon):
# The expense employee is not able to approve itself the expense sheet.
with self.assertRaises(UserError):
expense_sheet.with_user(self.expense_user_employee).approve_expense_sheets()
expense_sheet.with_user(self.expense_user_employee).action_approve_expense_sheets()
self.assertRecordValues(expense_sheet, [{'state': 'submit'}])
# An expense manager is required for this step.
expense_sheet.with_user(self.expense_user_manager).approve_expense_sheets()
expense_sheet.with_user(self.expense_user_manager).action_approve_expense_sheets()
self.assertRecordValues(expense_sheet, [{'state': 'approve'}])
# An expense manager is not able to create the journal entry.
@@ -108,10 +108,11 @@ class TestExpensesAccessRights(TestExpenseCommon):
# The expense employee is not able to refuse itself the expense sheet.
with self.assertRaises(UserError):
expense_sheet.with_user(self.expense_user_employee).refuse_sheet('')
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).refuse_sheet('')
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'}])
@@ -12,6 +12,8 @@ class TestExpenseMultiCompany(TestExpenseCommon):
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)\
@@ -44,14 +46,14 @@ class TestExpenseMultiCompany(TestExpenseCommon):
expense_sheet\
.with_user(self.expense_user_manager)\
.with_context(allowed_company_ids=self.company_data['company'].ids)\
.approve_expense_sheets()
.action_approve_expense_sheets()
# 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)\
.approve_expense_sheets()
.action_approve_expense_sheets()
# An expense manager having accounting access rights is not able to create the journal entry without access
# to company_2.
@@ -106,11 +108,11 @@ class TestExpenseMultiCompany(TestExpenseCommon):
expense_sheet\
.with_user(self.expense_user_manager)\
.with_context(allowed_company_ids=self.company_data['company'].ids)\
.refuse_sheet('')
._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)\
.refuse_sheet('')
._do_refuse('failed')
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="view_payment_form_inherit_expense" model="ir.ui.view">
<field name="name">account.payment.form.inherit</field>
<field name="model">account.payment</field>
<field name="inherit_id" ref="account.view_account_payment_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="inside">
<field name="expense_sheet_id" invisible="1"/>
<button name="action_open_expense_report"
class="oe_stat_button"
string="Expense Report"
icon="fa-file-text-o"
type="object"
attrs="{'invisible': [('expense_sheet_id', '=', [])]}"/>
</xpath>
</field>
</record>
</data>
</odoo>
+37 -21
View File
@@ -50,7 +50,7 @@
</header>
<field name="company_id" invisible="1"/>
<field name="company_currency_id" invisible="1"/>
<field name="attachment_number" invisible="True"/>
<field name="attachment_number" invisible="1"/>
<field name="date" optional="show"/>
<field name="product_id" optional="hide"/>
<field name="name"/>
@@ -531,8 +531,8 @@
</group>
<group string="Accounting">
<field name="property_account_expense_id" groups="account.group_account_readonly"/>
<field name="supplier_taxes_id" widget="many2many_tags"
context="{'default_type_tax_use':'purchase', 'default_price_include': 1}"
<field name="supplier_taxes_id" widget="many2many_tags"
context="{'default_type_tax_use':'purchase', 'default_price_include': 1}"
options="{'no_quick_create': True}"/>
</group>
</group>
@@ -632,6 +632,7 @@
<field name="journal_id" optional="hide"/>
<field name="state" optional="show" decoration-info="state == 'draft'" decoration-success="state in ['submit', 'approve', 'post', 'done']" decoration-danger="state == 'cancel'" widget="badge"/>
<field name="payment_state" optional="show" decoration-info="payment_state in ('partial','in_payment')" decoration-success="payment_state == 'paid'" decoration-danger="payment_state in ('reversed','not_paid')" widget="badge"/>
<field name="payment_mode" optional="hide"/>
</tree>
</field>
</record>
@@ -660,8 +661,13 @@
<field name="can_approve" invisible="1"/>
<field name="company_id" invisible="1"/>
<header>
<button name="action_submit_sheet" states="draft" string="Submit to Manager" type="object" class="oe_highlight o_expense_sheet_submit" data-hotkey="l"/>
<button name="approve_expense_sheets"
<button name="action_submit_sheet"
string="Submit to Manager"
attrs="{'invisible': [('state', '!=', 'draft')]}"
type="object"
class="oe_highlight o_expense_sheet_submit"
data-hotkey="l"/>
<button name="action_approve_expense_sheets"
string="Approve"
type="object"
data-hotkey="v"
@@ -676,21 +682,30 @@
attrs="{'invisible': [('state', '!=', 'approve')]}"
groups="account.group_account_invoice"/>
<button name="action_register_payment"
string="Register Payment"
type="object"
data-hotkey="w"
class="oe_highlight o_expense_sheet_pay"
attrs="{'invisible': [('state', '!=', 'post')]}"
context="{'dont_redirect_to_payments': True}"
string="Register Payment"
attrs="{'invisible': ['|', ('payment_state', 'in', ('paid', 'in_payment')), ('state', 'not in', ('post', 'done'))]}"
groups="account.group_account_invoice"/>
<button name="action_unpost"
<button name="action_refuse_expense_sheets"
string="Refuse"
attrs="{'invisible': [('state', 'not in', ('submit', 'approve'))]}"
type="object"
groups="hr_expense.group_hr_expense_team_approver"
data-hotkey="x"/>
<button name="action_reset_approval_expense_sheets"
string="Reset to Draft"
type="object"
attrs="{'invisible': ['|', ('can_reset', '=', False), ('state', 'not in', ('submit', 'cancel', 'approve'))]}"
data-hotkey="q"/>
<button name="action_reset_expense_sheets"
string="Reset to Draft"
type="object"
data-hotkey="c"
attrs="{'invisible': [('state', '!=', 'post')]}"
string="Cancel"
groups="account.group_account_readonly,account.group_account_invoice"/>
<button name="%(hr_expense.hr_expense_refuse_wizard_action)d" states="submit,approve" context="{'hr_expense_refuse_model':'hr.expense.sheet'}" string="Refuse" type="action" groups="hr_expense.group_hr_expense_team_approver" data-hotkey="x" />
<button name="reset_expense_sheets" string="Reset to Draft" type="object" attrs="{'invisible': ['|', ('can_reset', '=', False), ('state', 'not in', ['submit', 'cancel', 'approve'])]}" data-hotkey="q"/>
<field name="state" widget="statusbar" statusbar_visible="draft,submit,approve,post,done" force_save="1"/>
</header>
<sheet>
@@ -700,11 +715,11 @@
icon="fa-file-text-o"
type="object"
attrs="{'invisible': ['|', ('state', 'not in', ['post', 'done']), ('account_move_id', '=', False)]}"
groups="account.group_account_user,account.group_account_readonly">
groups="account.group_account_invoice">
Journal Entry
</button>
<field name="account_move_id" invisible="1"/>
<button name="action_get_expense_view"
<button name="action_open_expense_view"
class="oe_stat_button"
icon="fa-file-text-o"
type="object"
@@ -713,8 +728,7 @@
</button>
</div>
<field name="payment_state" invisible="True"/>
<widget name="web_ribbon" title="Posted" bg_color="bg-success" attrs="{'invisible': ['|', ('payment_state', '!=', 'paid'), ('payment_mode', '==', 'own_account')]}"/>
<widget name="web_ribbon" title="Paid" bg_color="bg-success" attrs="{'invisible': ['|', ('payment_state', '!=', 'paid'), ('payment_mode', '==', 'company_account')]}"/>
<widget name="web_ribbon" title="Paid" bg_color="bg-success" attrs="{'invisible': [('payment_state', '!=', 'paid')]}"/>
<widget name="web_ribbon" title="Partial" bg_color="bg-info" attrs="{'invisible': [('payment_state', '!=', 'partial')]}"/>
<widget name="web_ribbon" title="In Payment" attrs="{'invisible': [('payment_state', '!=', 'in_payment')]}"/>
<div class="oe_title">
@@ -727,8 +741,12 @@
<group name="employee_details">
<field name="employee_id" context="{'default_company_id': company_id}" widget="many2one_avatar_employee"/>
<field name="payment_mode"/>
<field name="journal_id" groups="account.group_account_invoice,account.group_account_readonly" options="{'no_open': True, 'no_create': True}" attrs="{'invisible': [('payment_mode', '!=', 'own_account')]}" context="{'default_company_id': company_id}"/>
<field name="bank_journal_id" groups="account.group_account_invoice,account.group_account_readonly" options="{'no_open': True, 'no_create': True}" attrs="{'invisible': [('payment_mode', '!=', 'company_account')]}" context="{'default_company_id': company_id}"/>
<field name="employee_journal_id" groups="account.group_account_invoice,account.group_account_readonly" options="{'no_open': True, 'no_create': True}" attrs="{'invisible': [('payment_mode', '!=', 'own_account')]}" context="{'default_company_id': company_id}"/>
<field name="selectable_payment_method_line_ids" invisible="1"/>
<field name="payment_method_line_id"
context="{'show_payment_journal_id': 1}"
options="{'no_open': True, 'no_create': True}"
attrs="{'invisible': [('payment_mode', '!=', 'company_account')], 'required': [('payment_mode', '=', 'company_account')], 'readonly': [('state', 'in', ('done', 'post'))]}"/>
<field name="address_id" invisible="1" context="{'default_company_id': company_id}"/>
<field name="department_id" invisible="1" context="{'default_company_id': company_id}"/>
</group>
@@ -747,10 +765,10 @@
mode="tree,kanban"
domain="[('state', '=', 'draft'), ('employee_id', '=', employee_id), ('company_id', '=', company_id)]"
options="{'reload_on_button': True}"
context="{'form_view_ref' : 'hr_expense.hr_expense_view_form_without_header', 'default_company_id': company_id, 'default_employee_id': employee_id}"
context="{'form_view_ref' : 'hr_expense.hr_expense_view_form_without_header', 'default_company_id': company_id, 'default_employee_id': employee_id}"
attrs="{'readonly': [('is_editable', '=', False)]}"
force_save="1">
<tree decoration-danger="is_refused" editable="bottom" class="o_expense_line_list">
<tree editable="bottom" class="o_expense_line_list">
<field name="date" optional="show"/>
<field name="product_id"/>
<field name="name"/>
@@ -776,7 +794,6 @@
<field name="amount_tax_company" optional="hide" readonly="True"/>
<field name="company_currency_id" invisible="1"/>
<field name="total_amount_company" optional="show" groups="base.group_multi_currency" readonly="True"/>
<field name="is_refused" invisible="True"/>
</tree>
</field>
<field name="currency_id" invisible="1"/>
@@ -1042,7 +1059,6 @@
<field name="view_mode">graph,pivot,tree,kanban,form</field>
<field name="search_view_id" ref="hr_expense_sheet_view_search"/>
<field name="domain">[]</field>
<field name="context">{}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new expense report
@@ -37,8 +37,10 @@
<setting company_dependent="1" help="Default accounting journal for expenses paid by employees." string="Employee Expense Journal">
<field name="expense_journal_id"/>
</setting>
<setting company_dependent="1" string="Company Expense Journal" help="Default accounting journal for expenses paid by the company.">
<field name="company_expense_journal_id"/>
</block>
<block title="Payment">
<setting company_dependent="1" string="Company payment methods available" help="Payment method allowed for expenses paid by company.">
<field name="company_expense_allowed_payment_method_line_ids" widget="many2many_tags" placeholder="All payment methods allowed" options="{'no_create': True}" context="{'show_payment_journal_id': 1}"/>
</setting>
</block>
</app>
@@ -29,6 +29,8 @@ class HrExpenseApproveDuplicate(models.TransientModel):
def action_approve(self):
self.sheet_ids._do_approve()
return {'type': 'ir.actions.act_window_close'}
def action_refuse(self):
self.sheet_ids.refuse_sheet(_('Duplicate Expense'))
self.sheet_ids._do_refuse(_('Duplicate Expense'))
return {'type': 'ir.actions.act_window_close'}
@@ -5,41 +5,21 @@ from odoo import api, fields, models
class HrExpenseRefuseWizard(models.TransientModel):
"""This wizard can be launched from an he.expense (an expense line)
or from an hr.expense.sheet (En expense report)
'hr_expense_refuse_model' must be passed in the context to differentiate
the right model to use.
"""
""" Wizard to specify reason on expense sheet refusal """
_name = "hr.expense.refuse.wizard"
_description = "Expense Refuse Reason Wizard"
reason = fields.Char(string='Reason', required=True)
hr_expense_ids = fields.Many2many('hr.expense')
hr_expense_sheet_id = fields.Many2one('hr.expense.sheet')
sheet_ids = fields.Many2many('hr.expense.sheet')
@api.model
def default_get(self, fields):
res = super(HrExpenseRefuseWizard, self).default_get(fields)
active_ids = self.env.context.get('active_ids', [])
refuse_model = self.env.context.get('hr_expense_refuse_model')
if refuse_model == 'hr.expense':
res.update({
'hr_expense_ids': active_ids,
'hr_expense_sheet_id': False,
})
elif refuse_model == 'hr.expense.sheet':
res.update({
'hr_expense_sheet_id': active_ids[0] if active_ids else False,
'hr_expense_ids': [],
})
res = super().default_get(fields)
if 'sheet_ids' in fields:
res['sheet_ids'] = self.env.context.get('active_ids', [])
return res
def expense_refuse_reason(self):
self.ensure_one()
if self.hr_expense_ids:
self.hr_expense_ids.refuse_expense(self.reason)
if self.hr_expense_sheet_id:
self.hr_expense_sheet_id.refuse_sheet(self.reason)
def action_refuse(self):
self.sheet_ids._do_refuse(self.reason)
return {'type': 'ir.actions.act_window_close'}
@@ -6,11 +6,10 @@
<field name="arch" type="xml">
<form string="Expense refuse reason">
<separator string="Reason to refuse Expense"/>
<field name="hr_expense_ids" invisible="1"/>
<field name="hr_expense_sheet_id" invisible="1"/>
<field name="sheet_ids" invisible="1"/>
<field name="reason" class="w-100"/>
<footer>
<button string='Refuse' name="expense_refuse_reason" type="object" class="oe_highlight" data-hotkey="q"/>
<button string='Refuse' name="action_refuse" type="object" class="oe_highlight" data-hotkey="q"/>
<button string="Cancel" class="oe_link" special="cancel" data-hotkey="z"/>
</footer>
</form>
+5
View File
@@ -894,6 +894,11 @@ class Project(models.Model):
def _get_profitability_sequence_per_invoice_type(self):
return {}
def _get_already_included_profitability_invoice_line_ids(self):
# To be extended to avoid account.move.line overlap between
# profitability reports.
return []
def _get_user_values(self):
return {
'is_project_user': self.user_has_groups('project.group_project_user'),
+12 -1
View File
@@ -68,11 +68,22 @@ class Project(models.Model):
sequence_per_invoice_type['expenses'] = 11
return sequence_per_invoice_type
def _get_already_included_profitability_invoice_line_ids(self):
# As both purchase orders and expenses (paid by employee) create vendor bills,
# we need to make sure they are exclusive in the profitability report.
move_line_ids = super()._get_already_included_profitability_invoice_line_ids()
query = self.env['account.move.line']._search([
('move_id.expense_sheet_id', '!=', False),
('id', 'not in', move_line_ids),
])
query.order = None
return move_line_ids + list(query)
def _get_expenses_profitability_items(self, with_action=True):
if not self.analytic_account_id:
return {}
can_see_expense = with_action and self.user_has_groups('hr_expense.group_hr_expense_team_approver')
query = self.env['hr.expense']._search([('is_refused', '=', False), ('state', 'in', ['approved', 'done'])])
query = self.env['hr.expense']._search([('state', 'in', ['approved', 'done'])])
query.order = None
query.add_where('hr_expense.analytic_distribution ? %s', [str(self.analytic_account_id.id)])
query_string, query_param = query.select('array_agg(id) as ids', 'SUM(untaxed_amount) as untaxed_amount')
@@ -27,7 +27,7 @@ class TestProjectHrExpenseProfitabilityCommon(TestExpenseCommon):
'No data should be found since the sheet is not approved yet.',
)
expense_sheet.approve_expense_sheets()
expense_sheet.action_approve_expense_sheets()
self.assertEqual(expense_sheet.state, 'approve')
return expense_sheet
@@ -65,7 +65,7 @@ class TestProjectHrExpenseProfitability(TestProjectProfitabilityCommon, TestProj
},
)
expense_sheet.refuse_sheet('Test cancel expense')
expense_sheet._do_refuse('Test cancel expense')
self.assertDictEqual(
self.project._get_profitability_items(False),
self.project_profitability_items_empty,
+1 -1
View File
@@ -134,7 +134,7 @@ class Project(models.Model):
**pol,
'invoice_lines': self.env['purchase.order.line'].browse(pol['id']).invoice_lines, # One2Many cannot be queried, they are not columns
} for pol in self._cr.dictfetchall()]
purchase_order_line_invoice_line_ids = []
purchase_order_line_invoice_line_ids = self._get_already_included_profitability_invoice_line_ids()
if purchase_order_line_read:
amount_invoiced = amount_to_invoice = 0.0
purchase_order_line_ids = []
@@ -13,7 +13,7 @@ class Project(models.Model):
if not self.analytic_account_id:
return {}
can_see_expense = with_action and self.user_has_groups('hr_expense.group_hr_expense_team_approver')
query = self.env['hr.expense']._search([('is_refused', '=', False), ('state', 'in', ['approved', 'done'])])
query = self.env['hr.expense']._search([('state', 'in', ['approved', 'done'])])
query.add_where('hr_expense.analytic_distribution ? %s', [str(self.analytic_account_id.id)])
query.order = None
query_string, query_param = query.select('sale_order_id', 'product_id', 'array_agg(id) as ids', 'SUM(untaxed_amount) as untaxed_amount')
@@ -118,7 +118,7 @@ class TestProjectSaleExpenseProfitability(TestProjectProfitabilityCommon, TestPr
{'id': 'expenses', 'sequence': expense_sequence, 'billed': -280.0, 'to_bill': 0.0},
)
expense_sheet.refuse_sheet('Test Cancel Expense')
expense_sheet._do_refuse('Test Cancel Expense')
expense_profitability = self.project._get_expenses_profitability_items(False)
self.assertDictEqual(
expense_profitability.get('revenues', {}),
+1 -1
View File
@@ -82,7 +82,7 @@ class TestReInvoice(TestExpenseCommon, TestSaleCommon):
],
})
expense_sheet.approve_expense_sheets()
expense_sheet.action_approve_expense_sheets()
expense_sheet.action_sheet_move_create()
self.assertRecordValues(sale_order.order_line, [
@@ -8,7 +8,7 @@ from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestSaleExpense(TestExpenseCommon, TestSaleCommon):
def test_sale_expense(self):
""" Test the behaviour of sales orders when managing expenses """
@@ -32,7 +32,7 @@ class TestSaleExpense(TestExpenseCommon, TestSaleCommon):
# create some expense and validate it (expense at cost)
# Submit to Manager
sheet = self.env['hr.expense.sheet'].create({
'name': 'Expense for John Smith',
'name': 'Expense for John Smith 1',
'employee_id': self.expense_employee.id,
'journal_id': self.company_data['default_journal_purchase'].id,
})
@@ -46,7 +46,7 @@ class TestSaleExpense(TestExpenseCommon, TestSaleCommon):
'sale_order_id': so.id,
})
# Approve
sheet.approve_expense_sheets()
sheet.action_approve_expense_sheets()
# Create Expense Entries
sheet.action_sheet_move_create()
# expense should now be in sales order
@@ -70,7 +70,7 @@ class TestSaleExpense(TestExpenseCommon, TestSaleCommon):
})
# Submit to Manager
sheet = self.env['hr.expense.sheet'].create({
'name': 'Expense for John Smith',
'name': 'Expense for John Smith 2',
'employee_id': self.expense_employee.id,
'journal_id': self.company_data['default_journal_purchase'].id,
})
@@ -86,7 +86,7 @@ class TestSaleExpense(TestExpenseCommon, TestSaleCommon):
'sale_order_id': so.id,
})
# Approve
sheet.approve_expense_sheets()
sheet.action_approve_expense_sheets()
# Create Expense Entries
sheet.action_sheet_move_create()
# expense should now be in sales order
@@ -6,7 +6,7 @@
<field name="model">hr.expense.sheet</field>
<field name="inherit_id" ref="hr_expense.view_hr_expense_sheet_form"/>
<field name="arch" type="xml">
<button name="action_get_expense_view" position="after">
<button name="action_open_expense_view" position="after">
<button name="action_open_sale_orders"
class="oe_stat_button"
icon="fa-money"
@@ -82,7 +82,7 @@ class TestExpenseMargin(TestExpenseCommon):
],
})
expense_sheet.approve_expense_sheets()
expense_sheet.action_approve_expense_sheets()
expense_sheet.action_sheet_move_create()
self.assertRecordValues(sale_order.order_line[1:], [