[IMP] hr_expense: show confirmation on duplicate expenses

Display a confirmation dialog when a user creates duplicate expenses.

Two expenses (or more) are considered duplicates when they share the same
date, amount, product, employee, company and currency.

closes odoo/odoo#64190

Taskid: 2368636
Related: odoo/upgrade#2061
Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
This commit is contained in:
Kevin Baptiste
2021-03-04 12:54:34 +00:00
committed by Yannick Tivisse
parent b85bde9854
commit 278f5a6995
9 changed files with 202 additions and 7 deletions
+1
View File
@@ -36,6 +36,7 @@ This module also uses analytic accounting and is compatible with the invoice on
'data/hr_expense_sequence.xml',
'data/hr_expense_data.xml',
'wizard/hr_expense_refuse_reason_views.xml',
'wizard/hr_expense_approve_duplicate_views.xml',
'views/hr_expense_views.xml',
'views/mail_activity_views.xml',
'security/ir_rule.xml',
+59 -4
View File
@@ -98,6 +98,8 @@ class HrExpense(models.Model):
('refused', 'Refused')
], compute='_compute_state', string='Status', copy=False, index=True, readonly=True, store=True, default='draft', help="Status of the expense.")
sheet_id = fields.Many2one('hr.expense.sheet', string="Expense Report", domain="[('employee_id', '=', employee_id), ('company_id', '=', company_id)]", readonly=True, copy=False)
approved_by = fields.Many2one('res.users', string='Approved By', related='sheet_id.user_id')
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)
@@ -105,6 +107,8 @@ class HrExpense(models.Model):
is_ref_editable = fields.Boolean("Reference Is Editable By Current User", compute='_compute_is_ref_editable')
product_has_cost = fields.Boolean("Is product with non zero cost selected", compute='_compute_product_has_cost')
same_currency = fields.Boolean("Is currency_id different from the company_currency_id", compute='_compute_same_currency')
duplicate_expense_ids = fields.Many2many('hr.expense', compute='_compute_duplicate_expense_ids')
sample = fields.Boolean()
label_total_amount_company = fields.Char(compute='_compute_label_total_amount_company')
label_convert_rate = fields.Char(compute='_compute_label_convert_rate')
@@ -234,6 +238,35 @@ class HrExpense(models.Model):
for expense in self:
expense.employee_id = self.env.user.with_company(expense.company_id).employee_id
@api.depends('employee_id', 'product_id', 'total_amount')
def _compute_duplicate_expense_ids(self):
self.duplicate_expense_ids = [(5, 0, 0)]
expenses = self.filtered(lambda e: e.employee_id and e.product_id and e.total_amount)
if expenses.ids:
duplicates_query = """
SELECT ARRAY_AGG(DISTINCT he.id)
FROM hr_expense AS he
JOIN hr_expense AS ex ON he.employee_id = ex.employee_id
AND he.product_id = ex.product_id
AND he.date = ex.date
AND he.total_amount = ex.total_amount
AND he.company_id = ex.company_id
AND he.currency_id = ex.currency_id
WHERE ex.id in %(expense_ids)s
GROUP BY he.employee_id, he.product_id, he.date, he.total_amount, he.company_id, he.currency_id
HAVING COUNT(he.id) > 1
"""
self.env.cr.execute(duplicates_query, {
'expense_ids': tuple(expenses.ids),
})
duplicates = [x[0] for x in self.env.cr.fetchall()]
for ids in duplicates:
exp = expenses.filtered(lambda e: e.id in ids)
exp.duplicate_expense_ids = [(6, 0, ids)]
expenses = expenses - exp
@api.onchange('product_id', 'date', 'account_id')
def _onchange_product_id_date_account_id(self):
rec = self.env['account.analytic.default'].sudo().account_get(
@@ -364,7 +397,7 @@ Or send your receipts at <a href="mailto:%(email)s?subject=Lunch%%20with%%20cust
sheet = self.env['hr.expense.sheet'].create({
'company_id': self.company_id.id,
'employee_id': self[0].employee_id.id,
'name': todo[0].name if len(todo) == 1 else '',
'name': todo[0].name if len(todo) == 1 else _('Expense Report'),
'expense_line_ids': [(6, 0, todo.ids)]
})
return sheet
@@ -388,6 +421,14 @@ Or send your receipts at <a href="mailto:%(email)s?subject=Lunch%%20with%%20cust
res['context'] = {'default_res_model': 'hr.expense', 'default_res_id': self.id}
return res
def action_approve_duplicates(self):
root = self.env['ir.model.data'].xmlid_to_res_id("base.partner_root")
for expense in self.duplicate_expense_ids:
expense.message_post(
body=_('%(user)s confirms this expense is not a duplicate with similar expense.', user=self.env.user.name),
author_id=root
)
# ----------------------------------------
# Business
# ----------------------------------------
@@ -855,6 +896,7 @@ class HrExpenseSheet(models.Model):
is_multiple_currency = fields.Boolean("Handle lines with different currencies", compute='_compute_is_multiple_currency')
can_reset = fields.Boolean('Can Reset', compute='_compute_can_reset')
can_approve = fields.Boolean('Can Approve', compute='_compute_can_approve')
approval_date = fields.Datetime('Approval Date', readonly=True)
_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'),
@@ -1003,7 +1045,7 @@ class HrExpenseSheet(models.Model):
self.write({'state': 'submit'})
self.activity_update()
def approve_expense_sheets(self):
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'):
@@ -1015,8 +1057,21 @@ class HrExpenseSheet(models.Model):
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()
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 _do_approve(self):
self._check_can_approve()
responsible_id = self.user_id.id or self.env.user.id
self.write({'state': 'approve', 'user_id': responsible_id})
self.write({'state': 'approve', 'user_id': responsible_id, 'approval_date': fields.Datetime.now()})
self.activity_update()
def paid_expense_sheets(self):
@@ -1043,7 +1098,7 @@ class HrExpenseSheet(models.Model):
if not self.can_reset:
raise UserError(_("Only HR Officers or the concerned employee can reset to draft."))
self.mapped('expense_line_ids').write({'is_refused': False})
self.write({'state': 'draft'})
self.write({'state': 'draft', 'approval_date': False})
self.activity_update()
return True
@@ -15,3 +15,4 @@ access_account_move_line_user,account.move.line.hr.expense.approver,account.mode
access_account_analytic_line_user,account.analytic.line.user,account.model_account_analytic_line,hr_expense.group_hr_expense_team_approver,1,1,1,1
access_mail_activity_type_expense_user,mail.activity.type.expense.user,mail.model_mail_activity_type,hr_expense.group_hr_expense_manager,1,1,1,1
access_hr_expense_refuse_wizard,access.hr.expense.refuse.wizard,model_hr_expense_refuse_wizard,hr_expense.group_hr_expense_team_approver,1,1,1,0
access_hr_expense_approve_duplicate,access.hr.expense.approve.duplicate,model_hr_expense_approve_duplicate,hr_expense.group_hr_expense_team_approver,1,1,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
15 access_account_analytic_line_user account.analytic.line.user account.model_account_analytic_line hr_expense.group_hr_expense_team_approver 1 1 1 1
16 access_mail_activity_type_expense_user mail.activity.type.expense.user mail.model_mail_activity_type hr_expense.group_hr_expense_manager 1 1 1 1
17 access_hr_expense_refuse_wizard access.hr.expense.refuse.wizard model_hr_expense_refuse_wizard hr_expense.group_hr_expense_team_approver 1 1 1 0
18 access_hr_expense_approve_duplicate access.hr.expense.approve.duplicate model_hr_expense_approve_duplicate hr_expense.group_hr_expense_team_approver 1 1 1 0
@@ -0,0 +1,65 @@
odoo.define('hr_expense.FormView', function (require) {
"use strict";
var Dialog = require('web.Dialog');
var FormController = require('web.FormController');
var FormView = require('web.FormView');
var viewRegistry = require('web.view_registry');
var core = require('web.core');
var _t = core._t;
var ExpenseFormController = FormController.extend({
_onButtonClicked: function (ev) {
if (ev.data.attrs.name === "action_submit_expenses") {
ev.stopPropagation();
var record = this.model.get(this.handle);
if (record.data.duplicate_expense_ids.count) {
var _super = this._super.bind(this, ev);
const recordID = record.data.id;
this._showConfirmDialog(_super).then(() => {
return this._rpc({
model: 'hr.expense',
method: 'action_approve_duplicates',
args: [recordID],
});
});
} else {
this._super.apply(this, arguments);
}
} else {
this._super.apply(this, arguments);
}
},
_showConfirmDialog: function(confirm_callback) {
return new Promise(function (resolve, reject) {
Dialog.confirm(this, _t("An expense of same category, amount and date already exists."), {
buttons: [
{
text: _t("Save Anyways"),
classes: 'btn-primary',
close: true,
click: function() {
confirm_callback();
resolve();
}
}, {
text: _t("Cancel"),
close: true,
click: reject,
}
],
});
});
}
});
var ExpenseFormView = FormView.extend({
config: _.extend({}, FormView.prototype.config, {
Controller: ExpenseFormController,
}),
});
viewRegistry.add('hr_expense_form_view', ExpenseFormView);
});
+1
View File
@@ -4,6 +4,7 @@
<template id="assets_backend" name="HR Expense Assets assets" inherit_id="web.assets_backend">
<xpath expr="." position="inside">
<script type="text/javascript" src="/hr_expense/static/src/js/expense_views.js"/>
<script type="text/javascript" src="/hr_expense/static/src/js/expense_form_view.js"/>
<script type="text/javascript" src="/hr_expense/static/src/js/expense_qr_code_action.js"/>
<script type="text/javascript" src="/hr_expense/static/src/js/upload_mixin.js"/>
</xpath>
+4 -3
View File
@@ -108,12 +108,12 @@
<field name="name">hr.expense.view.form</field>
<field name="model">hr.expense</field>
<field name="arch" type="xml">
<form string="Expenses" class="o_expense_form">
<form string="Expenses" class="o_expense_form" js_class="hr_expense_form_view">
<header>
<button name="action_submit_expenses" string="Create Report" type="object" class="oe_highlight o_expense_submit" attrs="{'invisible': ['|', ('attachment_number', '&lt;=', 0), ('sheet_id', '!=', False)]}"/>
<button name="action_submit_expenses" string="Create Report" type="object" class="oe_highlight o_expense_submit" attrs="{'invisible': ['|', '|', ('id', '=', False), ('attachment_number', '&lt;=', 0), ('sheet_id', '!=', False)]}"/>
<widget name="attach_document" string="Attach Receipt" action="message_post" attrs="{'invisible': ['|', ('attachment_number', '&lt;', 1), ('id','=',False)]}"/>
<widget name="attach_document" string="Attach Receipt" action="message_post" highlight="1" attrs="{'invisible': ['|',('attachment_number', '&gt;=', 1), ('id','=',False)]}"/>
<button name="action_submit_expenses" string="Create Report" type="object" class="o_expense_submit" attrs="{'invisible': ['|', ('attachment_number', '&gt;=', 1), ('sheet_id', '!=', False)]}"/>
<button name="action_submit_expenses" string="Create Report" type="object" class="o_expense_submit" attrs="{'invisible': ['|', '|', ('id', '=', False), ('attachment_number', '&gt;=', 1), ('sheet_id', '!=', False)]}"/>
<field name="state" widget="statusbar" statusbar_visible="draft,reported,approved,done,refused"/>
<button name="action_view_sheet" type="object" string="View Report" class="oe_highlight" attrs="{'invisible': [('sheet_id', '=', False)]}"/>
</header>
@@ -174,6 +174,7 @@
<field name="analytic_account_id" domain="['|', ('company_id', '=', company_id), ('company_id', '=', False)]" groups="analytic.group_analytic_accounting" attrs="{'readonly': [('is_editable', '=', False)]}"/>
<field name="analytic_tag_ids" widget="many2many_tags" groups="analytic.group_analytic_tags" attrs="{'readonly': [('is_editable', '=', False)]}"/>
<field name="company_id" groups="base.group_multi_company"/>
<field name="duplicate_expense_ids" invisible="1" />
</group><group attrs="{'invisible': [('product_has_cost', '=', True)]}">
<label for="payment_mode"/>
<div>
+1
View File
@@ -2,3 +2,4 @@
from . import hr_expense_refuse_reason
from . import account_payment_register
from . import hr_expense_approve_duplicate
@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
class HrExpenseApproveDuplicate(models.TransientModel):
"""
This wizard is shown whenever an approved expense is similar to one being
approved. The user has the opportunity to still validate it or decline.
"""
_name = "hr.expense.approve.duplicate"
_description = "Expense Approve Duplicate"
sheet_ids = fields.Many2many('hr.expense.sheet')
expense_ids = fields.Many2many('hr.expense', readonly=True)
@api.model
def default_get(self, fields):
res = super().default_get(fields)
if 'sheet_ids' in fields:
res['sheet_ids'] = [(6, 0, self.env.context.get('default_sheet_ids', []))]
if 'duplicate_expense_ids' in fields:
res['expense_ids'] = [(6, 0, self.env.context.get('default_expense_ids', []))]
return res
def action_approve(self):
self.sheet_ids._do_approve()
def action_refuse(self):
self.sheet_ids.refuse_sheet(_('Duplicate Expense'))
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="hr_expense_approve_duplicate_view_form" model="ir.ui.view">
<field name="model">hr.expense.approve.duplicate</field>
<field name="arch" type="xml">
<form string="Expense Validate Duplicate">
<field name="sheet_ids" invisible="1" />
<p>The following approved expenses have similar employee, amount and category than some expenses of this report. Please verify this report does not contain duplicates.</p>
<field name="expense_ids" nolabel="1">
<tree>
<field name="date" readonly="1" />
<field name="employee_id" readonly="1" />
<field name="product_id" readonly="1" />
<field name="total_amount_company" readonly="1" />
<field name="name" readonly="1" />
<field name="approved_by" readonly="1" />
<field name="approved_on" readonly="1" />
</tree>
</field>
<footer>
<button string="Refuse" class="btn-primary" name="action_refuse" type="object" attrs="{'invisible': [('sheet_ids', '=', [])]}" />
<button string="Approve" class="btn-secondary" name="action_approve" type="object" attrs="{'invisible': [('sheet_ids', '=', [])]}" />
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="hr_expense_approve_duplicate_action" model="ir.actions.act_window">
<field name="name">Validate Duplicate Expenses</field>
<field name="res_model">hr.expense.approve.duplicate</field>
<field name="view_mode">form</field>
<field name="view_id" ref="hr_expense_approve_duplicate_view_form"/>
<field name="target">new</field>
</record>
</odoo>