diff --git a/addons/hr_expense/__init__.py b/addons/hr_expense/__init__.py
index d28d51c0582..d84fb182e8f 100644
--- a/addons/hr_expense/__init__.py
+++ b/addons/hr_expense/__init__.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-import hr_department
-import hr_expense
+import models
import report
+import wizard
diff --git a/addons/hr_expense/__openerp__.py b/addons/hr_expense/__openerp__.py
index e13e0e39337..712d786cfad 100644
--- a/addons/hr_expense/__openerp__.py
+++ b/addons/hr_expense/__openerp__.py
@@ -4,7 +4,7 @@
{
'name': 'Expense Tracker',
- 'version': '1.0',
+ 'version': '2.0',
'category': 'Human Resources',
'sequence': 95,
'summary': 'Expenses Validation, Invoicing',
@@ -19,34 +19,29 @@ Employee can encode their own expenses and the validation flow puts it automatic
The whole flow is implemented as:
---------------------------------
* Draft expense
-* Confirmation of the sheet by the employee
-* Validation by his manager
+* Submitted by the employee to his manager
+* Approved by his manager
* Validation by the accountant and accounting entries creation
This module also uses analytic accounting and is compatible with the invoice on timesheet module so that you are able to automatically re-invoice your customers' expenses if your work by project.
""",
+ 'author': 'Odoo S.A.',
'website': 'https://www.odoo.com/page/expenses',
- 'depends': ['hr', 'account_accountant', 'report'],
+ 'depends': ['hr_contract', 'account_accountant', 'report'],
'data': [
'security/ir.model.access.csv',
- 'hr_expense_data.xml',
- 'hr_expense_sequence.xml',
- 'hr_expense_workflow.xml',
- 'hr_expense_view.xml',
- 'hr_expense_report.xml',
+ 'data/hr_expense_data.xml',
+ 'data/hr_expense_sequence.xml',
+ 'wizard/hr_expense_refuse_reason.xml',
+ 'views/hr_expense_views.xml',
'security/ir_rule.xml',
- 'report/hr_expense_report_view.xml',
- 'hr_expense_installer_view.xml',
+ 'views/hr_expense_installer_views.xml',
'views/report_expense.xml',
- 'hr_dashboard.xml',
- ],
- 'demo': ['hr_expense_demo.xml'],
- 'test': [
- '../account/test/account_minimal_test.xml',
- 'test/expense_demo.yml',
- 'test/expense_process.yml',
+ 'report/hr_expense_report_view.xml',
+ 'data/web_tip_data.xml',
+ 'views/hr_dashboard.xml',
],
+ 'demo': ['data/hr_expense_demo.xml'],
'installable': True,
- 'auto_install': False,
'application': True,
}
diff --git a/addons/hr_expense/hr_expense_data.xml b/addons/hr_expense/data/hr_expense_data.xml
similarity index 88%
rename from addons/hr_expense/hr_expense_data.xml
rename to addons/hr_expense/data/hr_expense_data.xml
index e26362a573d..8af41a40832 100644
--- a/addons/hr_expense/hr_expense_data.xml
+++ b/addons/hr_expense/data/hr_expense_data.xml
@@ -1,8 +1,8 @@
-
+
-
+ mail.channelnotification
@@ -21,19 +21,19 @@
Approved
- hr.expense.expense
+ hr.expenseExpense approvedRefused
- hr.expense.expense
-
+ hr.expense
+ Expense refusedTo Approve
- hr.expense.expense
+ hr.expenseExpense confirmed, waiting confirmation
@@ -48,4 +48,4 @@
-
+
diff --git a/addons/hr_expense/data/hr_expense_demo.xml b/addons/hr_expense/data/hr_expense_demo.xml
new file mode 100644
index 00000000000..d72639928c3
--- /dev/null
+++ b/addons/hr_expense/data/hr_expense_demo.xml
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+ 0.32
+ 0.32
+ service
+ Car Travel Expenses
+ CarTRA
+
+
+
+
+
+
+
+
+ 700.0
+ 700.0
+ service
+ Air Flight
+ AT
+
+
+
+
+
+
+ 400.0
+ 400.0
+ service
+ Hotel Accommodation
+ HA0
+
+
+
+
+
+
+
+
+ Travel by Air
+
+
+
+
+
+
+
+
+ Hotel Expenses - Thymbra
+
+
+
+
+
+
+
+
+
diff --git a/addons/hr_expense/hr_expense_sequence.xml b/addons/hr_expense/data/hr_expense_sequence.xml
similarity index 94%
rename from addons/hr_expense/hr_expense_sequence.xml
rename to addons/hr_expense/data/hr_expense_sequence.xml
index 543cac8719c..1138efedc56 100644
--- a/addons/hr_expense/hr_expense_sequence.xml
+++ b/addons/hr_expense/data/hr_expense_sequence.xml
@@ -1,5 +1,5 @@
-
+
@@ -10,4 +10,4 @@
-
+
diff --git a/addons/hr_expense/data/web_tip_data.xml b/addons/hr_expense/data/web_tip_data.xml
new file mode 100644
index 00000000000..eb24fcdcaa7
--- /dev/null
+++ b/addons/hr_expense/data/web_tip_data.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+ Select the lines you want to submit to be reimbursed.
+
+ hr.expense
+ list
+ input.oe_list_record_selector:visible
+ input.oe_list_record_selector:visible
+ bottom
+
+
+
+
+ Click on More to submit multiple expenses to your manager.
+
+ hr.expense
+ list
+ div.oe_sidebar.btn-group:visible
+ div.oe_sidebar.btn-group:visible
+ bottom
+
+
+
+
+ Use the messaging tool to log a note about your expense or to attach a document
+
+ hr.expense
+ form
+ div.btn-group.btn-group-sm:visible > button
+ .o_timeline_compose_post.btn.btn-primary:visible
+ bottom
+
+
+
\ No newline at end of file
diff --git a/addons/hr_expense/hr.expense.expense.csv b/addons/hr_expense/hr.expense.expense.csv
deleted file mode 100644
index 6147af8d754..00000000000
--- a/addons/hr_expense/hr.expense.expense.csv
+++ /dev/null
@@ -1,3 +0,0 @@
-"id","name","ref","employee_id","line_ids/date_value","line_ids/name","line_ids/analytic_account","line_ids/unit_quantity","line_ids/ref","line_ids/unit_amount"
-1,"September Expenses",09/06/09,"Fabien Pinckaers",05/09/06,"Travel by Car - Customer Seagate 2 - Double","Thymbra",130,,0.22
-,,,,05/09/06,"Basic PC - Server for Seagate","Seagate P2",1,"S1234435",300
diff --git a/addons/hr_expense/hr_department.py b/addons/hr_expense/hr_department.py
deleted file mode 100644
index b4fa2a5668f..00000000000
--- a/addons/hr_expense/hr_department.py
+++ /dev/null
@@ -1,16 +0,0 @@
-# -*- coding: utf-8 -*-
-from openerp import api, fields, models
-
-
-class hr_department(models.Model):
- _inherit = 'hr.department'
-
- @api.multi
- def _compute_expense_to_approve(self):
- expense_data = self.env['hr.expense.expense'].read_group([('department_id', 'in', self.ids), ('state', '=', 'confirm')], ['department_id'], ['department_id'])
- result = dict((data['department_id'][0], data['department_id_count']) for data in expense_data)
- for department in self:
- department.expense_to_approve_count = result.get(department.id, 0)
-
- expense_to_approve_count = fields.Integer(
- compute='_compute_expense_to_approve', string='Expenses to Approve')
diff --git a/addons/hr_expense/hr_expense.py b/addons/hr_expense/hr_expense.py
deleted file mode 100644
index b8f955a0048..00000000000
--- a/addons/hr_expense/hr_expense.py
+++ /dev/null
@@ -1,490 +0,0 @@
-# -*- coding: utf-8 -*-
-# Part of Odoo. See LICENSE file for full copyright and licensing details.
-
-import time
-
-from openerp.osv import fields, osv
-from openerp.tools.translate import _
-
-import openerp.addons.decimal_precision as dp
-from openerp.exceptions import UserError
-
-def _employee_get(obj, cr, uid, context=None):
- if context is None:
- context = {}
- ids = obj.pool.get('hr.employee').search(cr, uid, [('user_id', '=', uid)], context=context)
- if ids:
- return ids[0]
- return False
-
-class hr_expense_expense(osv.osv):
-
- def _amount(self, cr, uid, ids, field_name, arg, context=None):
- res= {}
- for expense in self.browse(cr, uid, ids, context=context):
- total = 0.0
- for line in expense.line_ids:
- total += line.unit_amount * line.unit_quantity
- res[expense.id] = total
- return res
-
- def _get_expense_from_line(self, cr, uid, ids, context=None):
- return [line.expense_id.id for line in self.pool.get('hr.expense.line').browse(cr, uid, ids, context=context)]
-
- def _get_currency(self, cr, uid, context=None):
- user = self.pool.get('res.users').browse(cr, uid, [uid], context=context)[0]
- return user.company_id.currency_id.id
-
- _name = "hr.expense.expense"
- _inherit = ['mail.thread', 'ir.needaction_mixin']
- _description = "Expense"
- _order = "id desc"
-
- _columns = {
- 'name': fields.char('Description', required=True, readonly=True, states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
- 'id': fields.integer('Sheet ID', readonly=True),
- 'date': fields.date('Date', select=True, readonly=True, states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
- 'journal_id': fields.many2one('account.journal', 'Force Journal', help = "The journal used when the expense is done."),
- 'employee_payable_account_id': fields.many2one('account.account', 'Employee Account', help="Employee payable account", domain=[('deprecated', '=', False)]),
- 'employee_id': fields.many2one('hr.employee', "Employee", required=True, readonly=True, states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
- 'user_id': fields.many2one('res.users', 'User', required=True),
- 'date_confirm': fields.date('Confirmation Date', select=True, copy=False,
- help="Date of the confirmation of the sheet expense. It's filled when the button Confirm is pressed."),
- 'date_valid': fields.date('Validation Date', select=True, copy=False,
- help="Date of the acceptation of the sheet expense. It's filled when the button Accept is pressed."),
- 'user_valid': fields.many2one('res.users', 'Validation By', readonly=True, copy=False,
- states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
- 'account_move_id': fields.many2one('account.move', 'Ledger Posting', copy=False, track_visibility="onchange"),
- 'line_ids': fields.one2many('hr.expense.line', 'expense_id', 'Expense Lines', copy=True,
- readonly=True, states={'draft':[('readonly',False)]} ),
- 'note': fields.text('Note'),
- 'amount': fields.function(_amount, string='Total Amount', digits=0,
- store={
- 'hr.expense.line': (_get_expense_from_line, ['unit_amount','unit_quantity'], 10)
- }),
- 'currency_id': fields.many2one('res.currency', 'Currency', required=True, readonly=True, states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
- 'department_id':fields.many2one('hr.department','Department', readonly=True, states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
- 'company_id': fields.many2one('res.company', 'Company', required=True),
- 'state': fields.selection([
- ('draft', 'New'),
- ('cancelled', 'Refused'),
- ('confirm', 'Waiting Approval'),
- ('accepted', 'Approved'),
- ('done', 'Waiting Payment'),
- ('paid', 'Paid'),
- ],
- 'Status', readonly=True, track_visibility='onchange', copy=False,
- help='When the expense request is created the status is \'Draft\'.\n It is confirmed by the user and request is sent to admin, the status is \'Waiting Confirmation\'.\
- \nIf the admin accepts it, the status is \'Accepted\'.\n If the accounting entries are made for the expense request, the status is \'Waiting Payment\'.'),
-
- }
- _defaults = {
- 'company_id': lambda s, cr, uid, c: s.pool.get('res.company')._company_default_get(cr, uid, 'hr.employee', context=c),
- 'date': fields.date.context_today,
- 'state': 'draft',
- 'employee_id': _employee_get,
- 'user_id': lambda cr, uid, id, c={}: id,
- 'currency_id': _get_currency,
- }
-
- def add_follower(self, cr, uid, ids, employee_id, context=None):
- employee = self.pool.get('hr.employee').browse(cr, uid, employee_id, context=context)
- if employee and employee.user_id:
- self.message_subscribe_users(cr, uid, ids, user_ids=[employee.user_id.id], context=context)
-
- def create(self, cr, uid, vals, context=None):
- employee_id = vals.get('employee_id', False)
- hr_expense_id = super(hr_expense_expense, self).create(cr, uid, vals, context=context)
- self.add_follower(cr, uid, [hr_expense_id], employee_id, context=context)
- return hr_expense_id
-
- def write(self, cr, uid, ids, vals, context=None):
- employee_id = vals.get('employee_id', False)
- hr_expense_id = super(hr_expense_expense, self).write(cr, uid, ids, vals, context=context)
- self.add_follower(cr, uid, ids, employee_id, context=context)
- return hr_expense_id
-
- def unlink(self, cr, uid, ids, context=None):
- for rec in self.browse(cr, uid, ids, context=context):
- if rec.state != 'draft':
- raise UserError(_('You can only delete draft expenses!'))
- return super(hr_expense_expense, self).unlink(cr, uid, ids, context)
-
- def onchange_currency_id(self, cr, uid, ids, currency_id=False, company_id=False, context=None):
- res = {'value': {'journal_id': False}}
- journal_ids = self.pool.get('account.journal').search(cr, uid, [('type','=','purchase'), ('currency_id','=',currency_id), ('company_id', '=', company_id)], context=context)
- if journal_ids:
- res['value']['journal_id'] = journal_ids[0]
- return res
-
- def onchange_employee_id(self, cr, uid, ids, employee_id, context=None):
- emp_obj = self.pool.get('hr.employee')
- department_id = False
- employee_payable_account_id = False
- company_id = False
- if employee_id:
- employee = emp_obj.browse(cr, uid, employee_id, context=context)
- department_id = employee.department_id.id
- company_id = employee.company_id.id
- if employee.address_home_id and employee.address_home_id.property_account_payable_id:
- employee_payable_account_id = employee.address_home_id.property_account_payable_id.id
- return {'value': {'department_id': department_id, 'company_id': company_id, 'employee_payable_account_id': employee_payable_account_id}}
-
- def expense_confirm(self, cr, uid, ids, context=None):
- for expense in self.browse(cr, uid, ids):
- if not expense.line_ids:
- raise UserError(_('You cannot submit expense which has no expense line.'))
- if expense.employee_id and expense.employee_id.parent_id.user_id:
- self.message_subscribe_users(cr, uid, [expense.id], user_ids=[expense.employee_id.parent_id.user_id.id])
- return self.write(cr, uid, ids, {'state': 'confirm', 'date_confirm': time.strftime('%Y-%m-%d')}, context=context)
-
- def expense_accept(self, cr, uid, ids, context=None):
- return self.write(cr, uid, ids, {'state': 'accepted', 'date_valid': time.strftime('%Y-%m-%d'), 'user_valid': uid}, context=context)
-
- def expense_canceled(self, cr, uid, ids, context=None):
- return self.write(cr, uid, ids, {'state': 'cancelled'}, context=context)
-
- def _track_subtype(self, cr, uid, ids, init_values, context=None):
- record = self.browse(cr, uid, ids[0], context=context)
- if 'state' in init_values and record.state == 'accepted':
- return 'hr_expense.mt_expense_approved'
- elif 'state' in init_values and record.state == 'confirm':
- return 'hr_expense.mt_expense_confirmed'
- elif 'state' in init_values and record.state == 'cancelled':
- return 'hr_expense.mt_expense_refused'
- return super(hr_expense_expense, self)._track_subtype(cr, uid, ids, init_values, context=context)
-
- def _notification_group_recipients(self, cr, uid, ids, message, recipients, done_ids, group_data, context=None):
- """ Override because HR users / officers can approve / refuse expenses
- directly form their notification email. """
- group_hr_user = self.pool['ir.model.data'].xmlid_to_res_id(cr, uid, 'base.group_hr_user')
- for recipient in recipients:
- if recipient.id in done_ids:
- continue
- if recipient.user_ids and group_hr_user in recipient.user_ids[0].groups_id.ids:
- group_data['group_hr_user'] |= recipient
- done_ids.add(recipient.id)
- return super(hr_expense_expense, self)._notification_group_recipients(cr, uid, ids, message, recipients, done_ids, group_data, context=context)
-
- def _notification_get_recipient_groups(self, cr, uid, ids, message, recipients, context=None):
- """ Override because HR users / officers can approve / refuse expenses
- directly form their notification email. """
- res = super(hr_expense_expense, self)._notification_get_recipient_groups(cr, uid, ids, message, recipients, context=context)
-
- app_action = self._notification_link_helper(cr, uid, ids, 'workflow', context=context, signal='validate')
- ref_action = self._notification_link_helper(cr, uid, ids, 'workflow', context=context, signal='refuse')
-
- expense = self.browse(cr, uid, ids[0], context=context)
- actions = []
- if expense.state == 'confirm':
- actions.append({'url': app_action, 'title': _('Approve')})
- if expense.state in ['confirm', 'accepted']:
- actions.append({'url': ref_action, 'title': _('Refuse')})
-
- res['group_hr_user'] = {
- 'actions': actions
- }
- return res
-
- def account_move_get(self, cr, uid, expense_id, context=None):
- '''
- This method prepare the creation of the account move related to the given expense.
-
- :param expense_id: Id of expense for which we are creating account_move.
- :return: mapping between fieldname and value of account move to create
- :rtype: dict
- '''
- journal_obj = self.pool.get('account.journal')
- expense = self.browse(cr, uid, expense_id, context=context)
- company_id = expense.company_id.id
- ref = expense.name
- journal_id = False
- if expense.journal_id:
- journal_id = expense.journal_id.id
- else:
- journal_id = journal_obj.search(cr, uid, [('type', '=', 'purchase'), ('company_id', '=', company_id)])
- if not journal_id:
- raise UserError(_("No expense journal found. Please make sure you have a journal with type 'purchase' configured."))
- journal_id = journal_id[0]
-
- return {
- 'journal_id': journal_id,
- 'date': expense.date_confirm,
- 'ref': ref,
- 'company_id': company_id,
- }
-
- def line_get_convert(self, cr, uid, x, part, date, context=None):
- partner_id = self.pool.get('res.partner')._find_accounting_partner(part).id
- return {
- 'date_maturity': x.get('date_maturity', False),
- 'partner_id': partner_id,
- 'name': x['name'][:64],
- 'date': date,
- 'debit': x['price']>0 and x['price'],
- 'credit': x['price']<0 and -x['price'],
- 'account_id': x['account_id'],
- 'analytic_line_ids': x.get('analytic_line_ids', False),
- 'amount_currency': x['price']>0 and abs(x.get('amount_currency', False)) or -abs(x.get('amount_currency', False)),
- 'currency_id': x.get('currency_id', False),
- 'tax_line_id': x.get('tax_line_id', False),
- 'ref': x.get('ref', False),
- 'quantity': x.get('quantity',1.00),
- 'product_id': x.get('product_id', False),
- 'product_uom_id': x.get('uom_id', False),
- 'analytic_account_id': x.get('account_analytic_id', False),
- }
-
- def compute_expense_totals(self, cr, uid, exp, company_currency, ref, account_move_lines, context=None):
- '''
- internal method used for computation of total amount of an expense in the company currency and
- in the expense currency, given the account_move_lines that will be created. It also do some small
- transformations at these account_move_lines (for multi-currency purposes)
-
- :param account_move_lines: list of dict
- :rtype: tuple of 3 elements (a, b ,c)
- a: total in company currency
- b: total in hr.expense currency
- c: account_move_lines potentially modified
- '''
- cur_obj = self.pool.get('res.currency')
- context = dict(context or {}, date=exp.date_confirm or time.strftime('%Y-%m-%d'))
- total = 0.0
- total_currency = 0.0
- for i in account_move_lines:
- if exp.currency_id.id != company_currency:
- i['currency_id'] = exp.currency_id.id
- i['amount_currency'] = i['price']
- i['price'] = cur_obj.compute(cr, uid, exp.currency_id.id,
- company_currency, i['price'],
- context=context)
- else:
- i['amount_currency'] = False
- i['currency_id'] = False
- total -= i['price']
- total_currency -= i['amount_currency'] or i['price']
- return total, total_currency, account_move_lines
-
- def action_move_create(self, cr, uid, ids, context=None):
- '''
- main function that is called when trying to create the accounting entries related to an expense
- '''
- move_obj = self.pool.get('account.move')
- for exp in self.browse(cr, uid, ids, context=context):
- if not exp.employee_payable_account_id:
- raise UserError(_('No employee account payable found for the expense '))
-
- company_currency = exp.company_id.currency_id.id
- diff_currency_p = exp.currency_id.id != company_currency
-
- #create the move that will contain the accounting entries
- move_id = move_obj.create(cr, uid, self.account_move_get(cr, uid, exp.id, context=context), context=context)
-
- #one account.move.line per expense line (+taxes..)
- eml = self.move_line_get(cr, uid, exp.id, context=context)
-
- #create one more move line, a counterline for the total on payable account
- total, total_currency, eml = self.compute_expense_totals(cr, uid, exp, company_currency, exp.name, eml, context=context)
-
- acc = exp.employee_payable_account_id.id or False
- eml.append({
- 'type': 'dest',
- 'name': '/',
- 'price': total,
- 'account_id': acc,
- 'date_maturity': exp.date_confirm,
- 'amount_currency': diff_currency_p and total_currency or False,
- 'currency_id': diff_currency_p and exp.currency_id.id or False,
- 'ref': exp.name
- })
-
- #convert eml into an osv-valid format
- lines = map(lambda x:(0,0,self.line_get_convert(cr, uid, x, exp.employee_id.company_id.partner_id, exp.date_confirm, context=context)), eml)
- journal_id = move_obj.browse(cr, uid, move_id, context).journal_id
- # post the journal entry if 'Skip 'Draft' State for Manual Entries' is checked
- move_obj.write(cr, uid, [move_id], {'line_ids': lines}, context=context)
- self.write(cr, uid, ids, {'account_move_id': move_id, 'state': 'done'}, context=context)
- move_obj.post(cr, uid, [move_id], context=context)
- return True
-
- def move_line_get(self, cr, uid, expense_id, context=None):
- res = []
- tax_obj = self.pool.get('account.tax')
- if context is None:
- context = {}
- exp = self.browse(cr, uid, expense_id, context=context)
-
- for line in exp.line_ids:
- mres = self.move_line_get_item(cr, uid, line, context)
- if not mres:
- continue
- res.append(mres)
-
- # Use the default tax on product or account
- taxes = []
- if line.product_id:
- fposition_id = False
- fpos_obj = self.pool.get('account.fiscal.position')
- fpos = fposition_id and fpos_obj.browse(cr, uid, fposition_id, context=context) or False
- product = line.product_id
- taxes = product.supplier_taxes_id
- # If taxes are not related to the product, maybe they are in the account
- if not taxes:
- a = product.property_account_expense_id.id or product.categ_id.property_account_expense_categ_id.id
- a = fpos_obj.map_account(cr, uid, fpos, a)
- taxes = a and self.pool.get('account.account').browse(cr, uid, a, context=context).tax_ids or False
- if not taxes:
- continue
-
- # Calculate tax lines and adjust base line
- tax_ids = [tax.id for tax in taxes]
- tax_res = tax_obj.browse(cr, uid, tax_ids, context=context).compute_all(line.unit_amount, exp.currency_id, line.unit_quantity, line.product_id, exp.user_id.partner_id)
- res[-1]['price'] = tax_res['total_excluded']
- res[-1]['tax_ids'] = tax_ids
- for tax in tax_res['taxes']:
- res.append({
- 'type': 'tax',
- 'name': tax['name'],
- 'price_unit': tax['amount'],
- 'quantity': 1,
- 'price': tax['amount'],
- 'account_id': tax['account_id'] or mres['account_id'],
- 'tax_line_id': tax['id'],
- })
- return res
-
- def move_line_get_item(self, cr, uid, line, context=None):
- company = line.expense_id.company_id
- property_obj = self.pool.get('ir.property')
- if line.product_id:
- acc = line.product_id.property_account_expense_id
- if not acc:
- acc = line.product_id.categ_id.property_account_expense_categ_id
- if not acc:
- raise UserError(_('No purchase account found for the product %s (or for his category), please configure one.') % (line.product_id.name))
- else:
- acc = property_obj.get(cr, uid, 'property_account_expense_categ_id', 'product.category', context={'force_company': company.id})
- if not acc:
- raise UserError(_('Please configure Default Expense account for Product purchase: `property_account_expense_categ_id`.'))
- return {
- 'type':'src',
- 'name': line.name.split('\n')[0][:64],
- 'price_unit':line.unit_amount,
- 'quantity':line.unit_quantity,
- 'price':line.total_amount,
- 'account_id':acc.id,
- 'product_id':line.product_id.id,
- 'uom_id':line.uom_id.id,
- 'account_analytic_id':line.analytic_account.id,
- }
-
- def action_view_move(self, cr, uid, ids, context=None):
- '''
- This function returns an action that display existing account.move of given expense ids.
- '''
- assert len(ids) == 1, 'This option should only be used for a single id at a time'
- expense = self.browse(cr, uid, ids[0], context=context)
- assert expense.account_move_id
- try:
- dummy, view_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'account', 'view_move_form')
- except ValueError, e:
- view_id = False
- result = {
- 'name': _('Expense Account Move'),
- 'view_type': 'form',
- 'view_mode': 'form',
- 'view_id': view_id,
- 'res_model': 'account.move',
- 'type': 'ir.actions.act_window',
- 'target': 'current',
- 'res_id': expense.account_move_id.id,
- }
- return result
-
-
-class product_template(osv.osv):
- _inherit = "product.template"
- _columns = {
- 'hr_expense_ok': fields.boolean('Can be Expensed', help="Specify if the product can be selected in an HR expense line."),
- }
-
-
-class hr_expense_line(osv.osv):
- _name = "hr.expense.line"
- _description = "Expense Line"
-
- def _amount(self, cr, uid, ids, field_name, arg, context=None):
- if not ids:
- return {}
- cr.execute("SELECT l.id,COALESCE(SUM(l.unit_amount*l.unit_quantity),0) AS amount FROM hr_expense_line l WHERE id IN %s GROUP BY l.id ",(tuple(ids),))
- res = dict(cr.fetchall())
- return res
-
- def _get_uom_id(self, cr, uid, context=None):
- result = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'product', 'product_uom_unit')
- return result and result[1] or False
-
- _columns = {
- 'name': fields.char('Expense Note', required=True),
- 'date_value': fields.date('Date', required=True),
- 'expense_id': fields.many2one('hr.expense.expense', 'Expense', ondelete='cascade', select=True),
- 'total_amount': fields.function(_amount, string='Total', digits=0),
- 'unit_amount': fields.float('Unit Price', digits_compute=dp.get_precision('Product Price')),
- 'unit_quantity': fields.float('Quantities', digits_compute= dp.get_precision('Product Unit of Measure')),
- 'product_id': fields.many2one('product.product', 'Product', domain=[('hr_expense_ok','=',True)], required=True),
- 'uom_id': fields.many2one('product.uom', 'Unit of Measure', required=True),
- 'description': fields.text('Description'),
- 'analytic_account': fields.many2one('account.analytic.account','Analytic account'),
- 'ref': fields.char('Reference'),
- 'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of expense lines."),
- }
- _defaults = {
- 'unit_quantity': 1,
- 'date_value': lambda *a: time.strftime('%Y-%m-%d'),
- 'uom_id': _get_uom_id,
- }
- _order = "sequence, date_value desc"
-
- def onchange_product_id(self, cr, uid, ids, product_id, context=None):
- res = {}
- if product_id:
- product = self.pool.get('product.product').browse(cr, uid, product_id, context=context)
- res['name'] = product.name
- amount_unit = product.price_get('standard_price')[product.id]
- res['unit_amount'] = amount_unit
- res['uom_id'] = product.uom_id.id
- return {'value': res}
-
- def onchange_uom(self, cr, uid, ids, product_id, uom_id, context=None):
- res = {'value':{}}
- if not uom_id or not product_id:
- return res
- product = self.pool.get('product.product').browse(cr, uid, product_id, context=context)
- uom = self.pool.get('product.uom').browse(cr, uid, uom_id, context=context)
- if uom.category_id.id != product.uom_id.category_id.id:
- res['warning'] = {'title': _('Warning'), 'message': _('Selected Unit of Measure does not belong to the same category as the product Unit of Measure')}
- res['value'].update({'uom_id': product.uom_id.id})
- return res
-
-
-class account_move_line(osv.osv):
- _inherit = "account.move.line"
-
- def reconcile(self, cr, uid, ids, writeoff_acc_id=False, writeoff_journal_id=False, context=None):
- res = super(account_move_line, self).reconcile(cr, uid, ids, writeoff_acc_id=writeoff_acc_id, writeoff_journal_id=writeoff_journal_id, context=context)
- #when making a full reconciliation of account move lines 'ids', we may need to recompute the state of some hr.expense
- account_move_ids = [aml.move_id.id for aml in self.browse(cr, uid, ids, context=context)]
- expense_obj = self.pool.get('hr.expense.expense')
- currency_obj = self.pool.get('res.currency')
- if account_move_ids:
- expense_ids = expense_obj.search(cr, uid, [('account_move_id', 'in', account_move_ids)], context=context)
- for expense in expense_obj.browse(cr, uid, expense_ids, context=context):
- if expense.state == 'done':
- #making the postulate it has to be set paid, then trying to invalidate it
- new_status_is_paid = True
- for aml in expense.account_move_id.line_ids:
- if aml.account_id.internal_type == 'payable' and not currency_obj.is_zero(cr, uid, expense.company_id.currency_id, aml.amount_residual):
- new_status_is_paid = False
- if new_status_is_paid:
- expense_obj.write(cr, uid, [expense.id], {'state': 'paid'}, context=context)
- return res
diff --git a/addons/hr_expense/hr_expense_demo.xml b/addons/hr_expense/hr_expense_demo.xml
deleted file mode 100644
index 85ca7547efd..00000000000
--- a/addons/hr_expense/hr_expense_demo.xml
+++ /dev/null
@@ -1,101 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
- 0.32
- service
- Car Travel Expenses
- CarTRA
-
-
-
-
-
-
-
-
- 700.0
- 700.0
- service
- Air Flight
- AT
-
-
-
-
-
-
- 400.0
- service
- Hotel Accommodation
- HA0
-
-
-
-
-
- Expenses
-
-
-
-
- Travel Expenses
-
-
-
-
-
-
-
-
-
-
-
- September Expenses
-
-
-
-
- Travel by Air
-
-
-
-
-
-
-
-
-
-
- Hotel Expenses - Thymbra
-
-
-
-
-
-
-
-
-
-
- Bruxelles - Paris
-
-
-
-
-
-
-
-
-
-
-
diff --git a/addons/hr_expense/hr_expense_installer_view.xml b/addons/hr_expense/hr_expense_installer_view.xml
deleted file mode 100644
index c329f9ea958..00000000000
--- a/addons/hr_expense/hr_expense_installer_view.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
- product.product.tree
- product.product
-
-
-
-
-
-
-
-
-
-
-
-
- Review Your Expenses Products
- ir.actions.act_window
- product.product
- form
- tree,form
-
- [('hr_expense_ok','=',True)]
-
- Define one product for each expense type allowed for an employee (travel by car, hostel, restaurant, etc). If you reimburse the employees at a fixed rate, set a cost and a unit of measure on the product. If you reimburse based on real costs, set the cost at 0.00. The user will set the real price when recording his expense sheet.
-
-
-
-
diff --git a/addons/hr_expense/hr_expense_report.xml b/addons/hr_expense/hr_expense_report.xml
deleted file mode 100644
index ab3200e5a54..00000000000
--- a/addons/hr_expense/hr_expense_report.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
diff --git a/addons/hr_expense/hr_expense_view.xml b/addons/hr_expense/hr_expense_view.xml
deleted file mode 100644
index 2d382b37f13..00000000000
--- a/addons/hr_expense/hr_expense_view.xml
+++ /dev/null
@@ -1,290 +0,0 @@
-
-
-
-
-
-
-
- hr.expense.line.tree
- hr.expense.line
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- hr.expense.expense.tree
- hr.expense.expense
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- hr.expense.expense.tree
- hr.expense.expense
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- hr.expense.form
- hr.expense.expense
-
-
-
-
-
-
-
-
- hr.expense.expense.filter
- hr.expense.expense
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Expenses
- hr.expense.expense
- form
-
-
-
-
- Click to register new expenses.
-
- Odoo will ensure the whole process is followed; the expense
- sheet is validated by manager(s), the employee is reimbursed
- from his expenses, some expenses must be re-invoiced to the
- customers.
-
- Odoo will ensure the whole process is followed; the expense
- sheet is validated by manager(s), the employee is reimbursed
- from his expenses, some expenses must be re-invoiced to the
- customers.
-
-
-
-
-
- Expenses to Approve
- hr.expense.expense
- form
- {'search_default_confirm':1, 'needaction_menu_ref': 'hr_expense.menu_expense_all'}
-
-
-
-
- Click to register new expenses.
-
- Odoo will ensure the whole process is followed; the expense
- sheet is validated by manager(s), the employee is reimbursed
- from his expenses, some expenses must be re-invoiced to the
- customers.
-
-
-
-
-
-
-
-
-
-
-
- Expenses to Approve
- hr.expense.expense
- form
- tree,form
-
- {
- 'search_default_confirm': 1,
- 'search_default_department_id': [active_id],
- 'default_department_id': active_id}
-
-
-
-
-
diff --git a/addons/hr_expense/hr_expense_workflow.xml b/addons/hr_expense/hr_expense_workflow.xml
deleted file mode 100644
index f8f2e9942c9..00000000000
--- a/addons/hr_expense/hr_expense_workflow.xml
+++ /dev/null
@@ -1,97 +0,0 @@
-
-
-
-
-
-
-
- hr.wkf.expenses
- hr.expense.expense
- True
-
-
-
-
- draft
- True
- function
- write({'state': 'draft'})
-
-
-
-
- confirm
- function
- expense_confirm()
-
-
-
-
- accepted
- function
- expense_accept()
-
-
-
-
- refused
- function
- expense_canceled()
-
-
-
-
- done
- function
- action_move_create()
-
-
-
-
-
- confirm
-
-
-
-
-
- validate
-
-
-
-
-
-
- refuse
-
-
-
-
-
-
- refuse
-
-
-
-
-
-
- draft
-
-
-
-
-
-
- done
-
-
-
-
-
- draft
-
-
-
-
-
diff --git a/addons/hr_expense/models/__init__.py b/addons/hr_expense/models/__init__.py
new file mode 100644
index 00000000000..9e63b72b7f9
--- /dev/null
+++ b/addons/hr_expense/models/__init__.py
@@ -0,0 +1,6 @@
+# -*- coding: utf-8 -*-
+
+import account_move_line
+import hr_department
+import hr_expense
+import product_template
diff --git a/addons/hr_expense/models/account_move_line.py b/addons/hr_expense/models/account_move_line.py
new file mode 100644
index 00000000000..bf408105b5a
--- /dev/null
+++ b/addons/hr_expense/models/account_move_line.py
@@ -0,0 +1,17 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from openerp import api, models
+
+
+class AccountMoveLine(models.Model):
+ _inherit = "account.move.line"
+
+ @api.multi
+ def reconcile(self, writeoff_acc_id=False, writeoff_journal_id=False):
+ res = super(AccountMoveLine, self).reconcile(writeoff_acc_id=writeoff_acc_id, writeoff_journal_id=writeoff_journal_id)
+ account_move_ids = [l.move_id.id for l in self if l.move_id.matched_percentage == 1]
+ if account_move_ids:
+ expenses = self.env['hr.expense'].search([('account_move_id', 'in', account_move_ids)])
+ expenses.paid_expenses()
+ return res
diff --git a/addons/hr_expense/models/hr_department.py b/addons/hr_expense/models/hr_department.py
new file mode 100644
index 00000000000..45433dde58e
--- /dev/null
+++ b/addons/hr_expense/models/hr_department.py
@@ -0,0 +1,16 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from openerp import fields, models
+
+
+class HrDepartment(models.Model):
+ _inherit = 'hr.department'
+
+ def _compute_expense_to_approve(self):
+ expense_data = self.env['hr.expense'].read_group([('department_id', 'in', self.ids), ('state', '=', 'submit')], ['department_id'], ['department_id'])
+ result = dict((data['department_id'][0], data['department_id_count']) for data in expense_data)
+ for department in self:
+ department.expense_to_approve_count = result.get(department.id, 0)
+
+ expense_to_approve_count = fields.Integer(compute='_compute_expense_to_approve', string='Expenses to Approve')
diff --git a/addons/hr_expense/models/hr_expense.py b/addons/hr_expense/models/hr_expense.py
new file mode 100644
index 00000000000..457d428a8f7
--- /dev/null
+++ b/addons/hr_expense/models/hr_expense.py
@@ -0,0 +1,295 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from openerp import api, fields, models, _
+from openerp.exceptions import UserError
+
+import openerp.addons.decimal_precision as dp
+
+
+class HrExpense(models.Model):
+
+ _name = "hr.expense"
+ _inherit = ['mail.thread', 'ir.needaction_mixin']
+ _description = "Expense"
+ _order = "date"
+
+ name = fields.Char(string='Description', readonly=True, required=True, states={'draft': [('readonly', False)]})
+ date = fields.Date(readonly=True, states={'draft': [('readonly', False)]}, default=fields.Date.context_today)
+ employee_id = fields.Many2one('hr.employee', string="Employee", required=True, readonly=True, states={'draft': [('readonly', False)]}, default=lambda self: self.env['hr.employee'].search([('user_id', '=', self.env.uid)], limit=1))
+ product_id = fields.Many2one('product.product', string='Product', readonly=True, states={'draft': [('readonly', False)]}, domain=[('can_be_expensed', '=', True)])
+ product_uom_id = fields.Many2one('product.uom', string='Unit of Measure', required=True, readonly=True, states={'draft': [('readonly', False)]}, default=lambda self: self.env['product.uom'].search([], limit=1, order='id'))
+ unit_amount = fields.Float(string='Unit Price', readonly=True, required=True, states={'draft': [('readonly', False)]}, digits=dp.get_precision('Product Price'))
+ quantity = fields.Float(required=True, readonly=True, states={'draft': [('readonly', False)]}, digits=dp.get_precision('Product Unit of Measure'), default=1)
+ tax_ids = fields.Many2many('account.tax', 'expense_tax', 'expense_id', 'tax_id', string='Taxes', states={'done': [('readonly', True)], 'post': [('readonly', True)]})
+ untaxed_amount = fields.Float(string='Subtotal', store=True, compute='_compute_amount', digits=dp.get_precision('Account'))
+ total_amount = fields.Float(string='Total', store=True, compute='_compute_amount', digits=dp.get_precision('Account'))
+ company_id = fields.Many2one('res.company', string='Company', readonly=True, states={'draft': [('readonly', False)]}, default=lambda self: self.env.user.company_id)
+ currency_id = fields.Many2one('res.currency', string='Currency', readonly=True, states={'draft': [('readonly', False)]}, default=lambda self: self.env.user.company_id.currency_id)
+ analytic_account_id = fields.Many2one('account.analytic.account', string='Contract', states={'post': [('readonly', True)], 'done': [('readonly', True)]}, oldname='analytic_account')
+ department_id = fields.Many2one('hr.department', string='Department', states={'post': [('readonly', True)], 'done': [('readonly', True)]})
+ description = fields.Text()
+ payment_mode = fields.Selection([("own_account", "By Employee's payment method"), ("company_account", "By Company's payment method")], default='own_account', states={'done': [('readonly', True)], 'post': [('readonly', True)]})
+ journal_id = fields.Many2one('account.journal', string='Expense Journal', states={'done': [('readonly', True)], 'post': [('readonly', True)]}, default=lambda self: self.env['account.journal'].search([('type', '=', 'purchase')], limit=1), required=True, 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)]}, default=lambda self: self.env['account.journal'].search([('type', 'in', ['case', 'bank'])], limit=1), help="The payment method used when the expense is paid by the company.")
+ account_move_id = fields.Many2one('account.move', string='Journal Entry', copy=False, track_visibility="onchange")
+ attachment_number = fields.Integer(compute='_compute_attachment_number', string='Number of Attachments')
+ state = fields.Selection([('draft', 'To Submit'),
+ ('submit', 'Submitted'),
+ ('approve', 'Approved'),
+ ('post', 'Waiting Payment'),
+ ('done', 'Paid'),
+ ('cancel', 'Refused')
+ ], string='Status', index=True, readonly=True, track_visibility='onchange', copy=False, default='draft', required=True,
+ help='When the expense request is created the status is \'To Submit\'.\n It is submitted by the employee and request is sent to manager, the status is \'Submitted\'.\
+ \nIf the manager approve it, the status is \'Approved\'.\n If the accountant genrate the accounting entries for the expense request, the status is \'Waiting Payment\'.')
+
+ @api.depends('quantity', 'unit_amount', 'tax_ids', 'currency_id')
+ def _compute_amount(self):
+ for expense in self:
+ expense.untaxed_amount = expense.unit_amount * expense.quantity
+ taxes = expense.tax_ids.compute_all(expense.unit_amount, expense.currency_id, expense.quantity, expense.product_id, expense.employee_id.user_id.partner_id)
+ expense.total_amount = taxes.get('total_included')
+
+ @api.multi
+ def _compute_attachment_number(self):
+ attachment_data = self.env['ir.attachment'].read_group([('res_model', '=', 'hr.expense'), ('res_id', 'in', self.ids)], ['res_id'], ['res_id'])
+ attachment = dict((data['res_id'], data['res_id_count']) for data in attachment_data)
+ for expense in self:
+ expense.attachment_number = attachment.get(expense.id, 0)
+
+ @api.onchange('product_id')
+ def _onchange_product_id(self):
+ if self.product_id:
+ self.name = '%s' % (self.product_id.display_name or '')
+ self.unit_amount = self.env['product.template']._price_get(self.product_id, 'standard_price')[self.product_id.id]
+ self.product_uom_id = self.product_id.uom_id
+
+ @api.onchange('product_uom_id')
+ def _onchange_product_uom_id(self):
+ if self.product_id and self.product_uom_id.category_id != self.product_id.uom_id.category_id:
+ raise UserError(_('Selected Unit of Measure does not belong to the same category as the product Unit of Measure'))
+
+ @api.onchange('employee_id')
+ def _onchange_employee_id(self):
+ self.department_id = self.employee_id.department_id
+
+ def _add_followers(self):
+ user_ids = []
+ employee = self.employee_id
+ if employee.user_id:
+ user_ids.append(employee.user_id.id)
+ if employee.parent_id:
+ user_ids.append(employee.parent_id.user_id.id)
+ if employee.department_id and employee.department_id.manager_id and employee.parent_id != employee.department_id.manager_id:
+ user_ids.append(employee.department_id.manager_id.user_id.id)
+ self.message_subscribe_users(user_ids=user_ids)
+
+ @api.model
+ def create(self, vals):
+ hr_expense = super(HrExpense, self).create(vals)
+ if vals.get('employee_id'):
+ hr_expense._add_followers()
+ return hr_expense
+
+ @api.multi
+ def write(self, vals):
+ res = super(HrExpense, self).write(vals)
+ if vals.get('employee_id'):
+ self._add_followers()
+ return res
+
+ @api.multi
+ def unlink(self):
+ if any(expense.state not in ['draft', 'cancel'] for expense in self):
+ raise UserError(_('You can only delete draft or refused expenses!'))
+ return super(HrExpense, self).unlink()
+
+ @api.multi
+ def submit_expenses(self):
+ if any(expense.state != 'draft' for expense in self):
+ raise UserError(_("You can only submit draft expenses!"))
+ self.write({'state': 'submit'})
+
+ @api.multi
+ def approve_expenses(self):
+ self.write({'state': 'approve'})
+
+ @api.multi
+ def refuse_expenses(self, reason):
+ self.write({'state': 'cancel'})
+ if self.employee_id.user_id:
+ body = (_("Your Expense %s has been refused.
Reason : %s
") % (self.name, reason))
+ self.message_post(body=body, partner_ids=[self.employee_id.user_id.partner_id.id])
+
+ @api.multi
+ def paid_expenses(self):
+ self.write({'state': 'done'})
+
+ @api.multi
+ def reset_expenses(self):
+ return self.write({'state': 'draft'})
+
+ @api.multi
+ def _track_subtype(self, init_values):
+ self.ensure_one()
+ if 'state' in init_values and self.state == 'approve':
+ return 'hr_expense.mt_expense_approved'
+ elif 'state' in init_values and self.state == 'submit':
+ return 'hr_expense.mt_expense_confirmed'
+ elif 'state' in init_values and self.state == 'cancel':
+ return 'hr_expense.mt_expense_refused'
+ return super(HrExpense, self)._track_subtype(init_values)
+
+ def _prepare_move_line(self, line):
+ '''
+ This function prepares move line of account.move related to an expense
+ '''
+ partner_id = self.employee_id.address_home_id.commercial_partner_id.id
+ return {
+ 'date_maturity': line.get('date_maturity'),
+ 'partner_id': partner_id,
+ 'name': line['name'][:64],
+ 'date': self.date,
+ 'debit': line['price'] > 0 and line['price'],
+ 'credit': line['price'] < 0 and -line['price'],
+ 'account_id': line['account_id'],
+ 'analytic_line_ids': line.get('analytic_line_ids'),
+ 'amount_currency': line['price'] > 0 and abs(line.get('amount_currency')) or -abs(line.get('amount_currency')),
+ 'currency_id': line.get('currency_id'),
+ 'tax_line_id': line.get('tax_line_id'),
+ 'ref': line.get('ref'),
+ 'quantity': line.get('quantity',1.00),
+ 'product_id': line.get('product_id'),
+ 'product_uom_id': line.get('uom_id'),
+ 'analytic_account_id': line.get('analytic_account_id'),
+ }
+
+ @api.multi
+ def _compute_expense_totals(self, company_currency, account_move_lines):
+ '''
+ internal method used for computation of total amount of an expense in the company currency and
+ in the expense currency, given the account_move_lines that will be created. It also do some small
+ transformations at these account_move_lines (for multi-currency purposes)
+
+ :param account_move_lines: list of dict
+ :rtype: tuple of 3 elements (a, b ,c)
+ a: total in company currency
+ b: total in hr.expense currency
+ c: account_move_lines potentially modified
+ '''
+ self.ensure_one()
+ total = 0.0
+ total_currency = 0.0
+ for line in account_move_lines:
+ line['currency_id'] = False
+ line['amount_currency'] = False
+ if self.currency_id != company_currency:
+ line['currency_id'] = self.currency_id.id
+ line['amount_currency'] = line['price']
+ line['price'] = self.currency_id.with_context(date=self.date or fields.Date.context_today(self)).compute(line['price'], company_currency)
+ total -= line['price']
+ total_currency -= line['amount_currency'] or line['price']
+ return total, total_currency, account_move_lines
+
+ @api.multi
+ def action_move_create(self):
+ '''
+ main function that is called when trying to create the accounting entries related to an expense
+ '''
+ if any(expense.state != 'approve' for expense in self):
+ raise UserError(_("You can only generate accounting entry for approved expense(s)."))
+
+ if any(expense.employee_id != self[0].employee_id for expense in self):
+ raise UserError(_("Expenses must belong to the same Employee."))
+
+ #create the move that will contain the accounting entries
+ move = self.env['account.move'].create({
+ 'journal_id': self.journal_id.id,
+ 'company_id': self.env.user.company_id.id,
+ })
+
+ for expense in self:
+ company_currency = expense.company_id.currency_id
+ diff_currency_p = expense.currency_id != company_currency
+ #one account.move.line per expense (+taxes..)
+ move_lines = expense._move_line_get()
+
+ #create one more move line, a counterline for the total on payable account
+ total, total_currency, move_lines = expense._compute_expense_totals(company_currency, move_lines)
+ if expense.payment_mode == 'company_account':
+ if not expense.bank_journal_id.default_credit_account_id:
+ raise UserError(_("No credit account found for the %s journal, please configure one.") % (expense.bank_journal_id.name))
+ emp_account = expense.bank_journal_id.default_credit_account_id.id
+ else:
+ if not expense.employee_id.address_home_id:
+ raise UserError(_("No Home Address found for the employee %s, please configure one.") % (expense.employee_id.name))
+ emp_account = expense.employee_id.address_home_id.property_account_payable_id.id
+
+ move_lines.append({
+ 'type': 'dest',
+ 'name': '/',
+ 'price': total,
+ 'account_id': emp_account,
+ 'date_maturity': expense.date,
+ 'amount_currency': diff_currency_p and total_currency or False,
+ 'currency_id': diff_currency_p and expense.currency_id.id or False,
+ 'ref': expense.employee_id.address_home_id.ref or False
+ })
+
+ #convert eml into an osv-valid format
+ lines = map(lambda x:(0, 0, expense._prepare_move_line(x)), move_lines)
+ move.write({'line_ids': lines})
+ expense.write({'account_move_id': move.id, 'state': 'post'})
+ if expense.payment_mode == 'company_account':
+ expense.paid_expenses()
+ return move.post()
+
+ @api.multi
+ def _move_line_get(self):
+ account_move = []
+ for expense in self:
+ if expense.product_id:
+ account = expense.product_id.product_tmpl_id._get_product_accounts()['expense']
+ if not account:
+ raise UserError(_("No Expense account found for the product %s (or for it's category), please configure one.") % (expense.product_id.name))
+ else:
+ account = self.env['ir.property'].with_context(force_company=expense.company_id.id).get('property_account_expense_categ_id', 'product.category')
+ if not account:
+ raise UserError(_('Please configure Default Expense account for Product expense: `property_account_expense_categ_id`.'))
+ move_line = {
+ 'type': 'src',
+ 'name': expense.name.split('\n')[0][:64],
+ 'price_unit': expense.unit_amount,
+ 'quantity': expense.quantity,
+ 'price': expense.total_amount,
+ 'account_id': account.id,
+ 'product_id': expense.product_id.id,
+ 'uom_id': expense.product_uom_id.id,
+ 'analytic_account_id': expense.analytic_account_id.id,
+ }
+ account_move.append(move_line)
+
+ # Calculate tax lines and adjust base line
+ taxes = expense.tax_ids.compute_all(expense.unit_amount, expense.currency_id, expense.quantity, expense.product_id)
+ account_move[-1]['price'] = taxes['total_excluded']
+ account_move[-1]['tax_ids'] = expense.tax_ids.id
+ for tax in taxes['taxes']:
+ account_move.append({
+ 'type': 'tax',
+ 'name': tax['name'],
+ 'price_unit': tax['amount'],
+ 'quantity': 1,
+ 'price': tax['amount'],
+ 'account_id': tax['account_id'] or move_line['account_id'],
+ 'tax_line_id': tax['id'],
+ })
+ return account_move
+
+ @api.multi
+ def action_get_attachment_view(self):
+ self.ensure_one()
+ res = self.env['ir.actions.act_window'].for_xml_id('base', 'action_attachment')
+ res['domain'] = [('res_model', '=', 'hr.expense'), ('res_id', 'in', self.ids)]
+ res['context'] = {'default_res_model': 'hr.expense', 'default_res_id': self.id}
+ return res
diff --git a/addons/hr_expense/models/product_template.py b/addons/hr_expense/models/product_template.py
new file mode 100644
index 00000000000..8b0d3057197
--- /dev/null
+++ b/addons/hr_expense/models/product_template.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from openerp import fields, models
+
+
+class ProductTemplate(models.Model):
+ _inherit = "product.template"
+
+ can_be_expensed = fields.Boolean(help="Specify whether the product can be selected in an HR expense.")
diff --git a/addons/hr_expense/report/__init__.py b/addons/hr_expense/report/__init__.py
index f8c7bee6c00..df9afa15d72 100644
--- a/addons/hr_expense/report/__init__.py
+++ b/addons/hr_expense/report/__init__.py
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
-# Part of Odoo. See LICENSE file for full copyright and licensing details.
import hr_expense_report
diff --git a/addons/hr_expense/report/hr_expense_report.py b/addons/hr_expense/report/hr_expense_report.py
index 68c2b999211..93cdf763fd3 100644
--- a/addons/hr_expense/report/hr_expense_report.py
+++ b/addons/hr_expense/report/hr_expense_report.py
@@ -1,92 +1,73 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from openerp import tools
-from openerp.osv import fields, osv
+from openerp import fields, models, tools
-from openerp.addons.decimal_precision import decimal_precision as dp
+import openerp.addons.decimal_precision as dp
-class hr_expense_report(osv.osv):
+class HrExpenseReport(models.Model):
_name = "hr.expense.report"
_description = "Expenses Statistics"
_auto = False
_rec_name = 'date'
- _columns = {
- 'date': fields.date('Date ', readonly=True),
- 'create_date': fields.datetime('Creation Date', readonly=True),
- 'product_id':fields.many2one('product.product', 'Product', readonly=True),
- 'journal_id': fields.many2one('account.journal', 'Force Journal', readonly=True),
- 'product_qty':fields.float('Product Quantity', readonly=True),
- 'employee_id': fields.many2one('hr.employee', "Employee's Name", readonly=True),
- 'date_confirm': fields.date('Confirmation Date', readonly=True),
- 'date_valid': fields.date('Validation Date', readonly=True),
- 'department_id':fields.many2one('hr.department','Department', readonly=True),
- 'company_id':fields.many2one('res.company', 'Company', readonly=True),
- 'user_id':fields.many2one('res.users', 'Validation User', readonly=True),
- 'currency_id': fields.many2one('res.currency', 'Currency', readonly=True),
- 'price_total':fields.float('Total Price', readonly=True, digits=0),
- 'delay_valid':fields.float('Delay to Valid', readonly=True),
- 'delay_confirm':fields.float('Delay to Confirm', readonly=True),
- 'analytic_account': fields.many2one('account.analytic.account','Analytic account',readonly=True),
- 'price_average':fields.float('Average Price', readonly=True, digits=0),
- 'nbr':fields.integer('# of Lines', readonly=True), # TDE FIXME master: rename into nbr_lines
- 'no_of_products':fields.integer('# of Products', readonly=True),
- 'no_of_account':fields.integer('# of Accounts', readonly=True),
- 'state': fields.selection([
- ('draft', 'Draft'),
- ('confirm', 'Waiting confirmation'),
- ('accepted', 'Accepted'),
- ('done', 'Done'),
- ('cancelled', 'Cancelled')],
- 'Status', readonly=True),
- }
+
+ date = fields.Date(readonly=True)
+ create_date = fields.Datetime(string='Creation Date', readonly=True)
+ product_id = fields.Many2one('product.product', string='Product', readonly=True)
+ journal_id = fields.Many2one('account.journal', string='Force Journal', readonly=True)
+ product_qty = fields.Float(string='Total Quantity', readonly=True)
+ employee_id = fields.Many2one('hr.employee', string="Employee's Name", readonly=True)
+ department_id = fields.Many2one('hr.department', string='Department', readonly=True)
+ company_id = fields.Many2one('res.company', string='Company', readonly=True)
+ currency_id = fields.Many2one('res.currency', string='Currency', readonly=True)
+ price_total = fields.Float(string='Total Price', readonly=True, digits=0)
+ analytic_account_id = fields.Many2one('account.analytic.account', string='Contract', readonly=True, oldname='analytic_account')
+ price_average = fields.Float(string='Average Price', readonly=True, digits=0)
+ nbr = fields.Integer(string='# of Expenses', readonly=True)
+ state = fields.Selection([
+ ('draft', 'Draft'),
+ ('submit', 'Submitted'),
+ ('approve', 'Approved'),
+ ('post', 'Waiting Payment'),
+ ('done', 'Paid'),
+ ('cancel', 'Refused')], readonly=True)
+
_order = 'date desc'
def init(self, cr):
tools.drop_view_if_exists(cr, 'hr_expense_report')
cr.execute("""
- create or replace view hr_expense_report as (
- select
- min(l.id) as id,
- s.date as date,
- s.create_date as create_date,
- s.employee_id,
- s.journal_id,
- s.currency_id,
- s.date_confirm as date_confirm,
- s.date_valid as date_valid,
- s.user_valid as user_id,
- s.department_id,
- avg(extract('epoch' from age(s.date_valid,s.date)))/(3600*24) as delay_valid,
- avg(extract('epoch' from age(s.date_valid,s.date_confirm)))/(3600*24) as delay_confirm,
- l.product_id as product_id,
- l.analytic_account as analytic_account,
- sum(l.unit_quantity * u.factor) as product_qty,
- s.company_id as company_id,
- sum(l.unit_quantity*l.unit_amount) as price_total,
- (sum(l.unit_quantity*l.unit_amount)/sum(case when l.unit_quantity=0 or u.factor=0 then 1 else l.unit_quantity * u.factor end))::decimal(16,2) as price_average,
- count(*) as nbr,
- (select unit_quantity from hr_expense_line where id=l.id and product_id is not null) as no_of_products,
- (select analytic_account from hr_expense_line where id=l.id and analytic_account is not null) as no_of_account,
- s.state
- from hr_expense_line l
- left join hr_expense_expense s on (s.id=l.expense_id)
- left join product_uom u on (u.id=l.uom_id)
- group by
- s.date,
- s.create_date,
- s.date_confirm,
- s.date_valid,
- l.product_id,
- l.analytic_account,
- s.currency_id,
- s.user_valid,
- s.department_id,
- l.uom_id,
- l.id,
- s.state,
- s.journal_id,
- s.company_id,
- s.employee_id
+ CREATE OR REPLACE VIEW hr_expense_report AS (
+ SELECT
+ MIN(e.id) AS id,
+ e.date AS date,
+ e.create_date AS create_date,
+ e.employee_id,
+ e.journal_id,
+ e.currency_id,
+ e.department_id,
+ e.product_id AS product_id,
+ e.analytic_account_id AS analytic_account_id,
+ SUM(e.quantity * u.factor) AS product_qty,
+ e.company_id AS company_id,
+ SUM(e.quantity * e.unit_amount) AS price_total,
+ (SUM(e.quantity * e.unit_amount) / SUM(CASE WHEN e.quantity=0 OR u.factor=0 THEN 1 ELSE e.quantity * u.factor END))::DECIMAL(16,2) AS price_average,
+ COUNT(*) AS nbr,
+ e.state
+ FROM hr_expense e
+ LEFT JOIN product_uom u ON (u.id=e.product_uom_id)
+ GROUP BY
+ e.date,
+ e.create_date,
+ e.product_id,
+ e.analytic_account_id,
+ e.currency_id,
+ e.department_id,
+ e.product_uom_id,
+ e.id,
+ e.state,
+ e.journal_id,
+ e.company_id,
+ e.employee_id
)
""")
diff --git a/addons/hr_expense/report/hr_expense_report_view.xml b/addons/hr_expense/report/hr_expense_report_view.xml
index 95b46b8bc0b..17011b86d8a 100644
--- a/addons/hr_expense/report/hr_expense_report_view.xml
+++ b/addons/hr_expense/report/hr_expense_report_view.xml
@@ -1,70 +1,69 @@
-
-
+
-
- hr.expense.report.pivot
- hr.expense.report
-
-
-
-
-
-
-
-
+
+ hr.expense.report.pivot
+ hr.expense.report
+
+
+
+
+
+
+
+
-
- hr.expense.report.graph
- hr.expense.report
-
-
-
-
-
-
-
+
+ hr.expense.report.graph
+ hr.expense.report
+
+
+
+
+
+
+
-
+ hr.expense.report.searchhr.expense.report
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
+
-
+ Expenses Analysishr.expense.reportformpivot,graph
- {'search_default_year':1,'search_default_month':1,'search_default_employee':1}
-
+ {'search_default_year':1, 'search_default_month':1, 'search_default_employee':1}
+
@@ -73,16 +72,15 @@
formpivot,graph{
- 'search_default_year':1,
- 'search_default_month':1,
- 'search_default_employee':1,
+ 'search_default_year': 1,
+ 'search_default_month': 1,
+ 'search_default_employee': 1,
'search_default_department_id': [active_id],
'default_department_id': active_id}
-
+
-
+
-
-
+
diff --git a/addons/hr_expense/security/ir.model.access.csv b/addons/hr_expense/security/ir.model.access.csv
index 3b59343b112..df0a3187f9e 100644
--- a/addons/hr_expense/security/ir.model.access.csv
+++ b/addons/hr_expense/security/ir.model.access.csv
@@ -1,8 +1,7 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
-access_hr_expense_expense_user,hr.expense.expense.user,model_hr_expense_expense,base.group_hr_user,1,1,1,1
-access_hr_expense_expense_employee,hr.expense.expense.employee,model_hr_expense_expense,base.group_user,1,1,1,1
-access_hr_expense_line_user,hr.expense.line.user,model_hr_expense_line,base.group_hr_user,1,1,1,1
-access_hr_expense_line_employee,hr.expense.line.employee,model_hr_expense_line,base.group_user,1,1,1,1
+
+access_hr_expense_user,hr.expense.user,model_hr_expense,base.group_hr_user,1,1,1,1
+access_hr_expense_employee,hr.expense.employee,model_hr_expense,base.group_user,1,1,1,1
access_hr_expense_report_manager,hr.expense.report.manager,model_hr_expense_report,base.group_hr_manager,1,1,1,1
access_product_product_hr_expense_user,product.product.hr.expense.user,product.model_product_product,base.group_hr_user,1,1,1,1
access_product_template_hr_expense_user,product.template.hr.expense.user,product.model_product_template,base.group_hr_user,1,1,1,1
@@ -11,7 +10,7 @@ access_account_journal_user,account.journal.user,account.model_account_journal,b
access_account_journal_employee,account.journal.employee,account.model_account_journal,base.group_user,1,0,0,0
access_account_invoice_user,account.invoice.user,account.model_account_invoice,base.group_hr_user,1,1,1,1
access_account_invoice_line_user,account.invoice.line.user,account.model_account_invoice_line,base.group_hr_user,1,1,1,1
-access_account_invoice_tax_user,account.invoice.tax.user,account.model_account_invoice_tax,base.group_hr_user,1,1,1,1
-access_account_move_user,account.move.user,account.model_account_move,base.group_hr_user,1,1,1,1
-access_account_move_line_user,account.move.line.user,account.model_account_move_line,base.group_hr_user,1,1,1,1
-access_account_analytic_line_user,account.ianalytic.line.user,account.model_account_analytic_line,base.group_hr_user,1,1,1,1
+access_account_invoice_tax_user,account.invoice.tax.user,account.model_account_invoice_tax,base.group_hr_user,1,0,0,0
+access_account_move_user,account.move.user,account.model_account_move,base.group_hr_user,1,0,0,0
+access_account_move_line_user,account.move.line.user,account.model_account_move_line,base.group_hr_user,1,0,0,0
+access_account_analytic_line_user,account.analytic.line.user,account.model_account_analytic_line,base.group_hr_user,1,1,1,1
diff --git a/addons/hr_expense/security/ir_rule.xml b/addons/hr_expense/security/ir_rule.xml
index 3c3cb018489..6d13ad5d62c 100644
--- a/addons/hr_expense/security/ir_rule.xml
+++ b/addons/hr_expense/security/ir_rule.xml
@@ -1,25 +1,25 @@
-
-
+
+Manager Expense
-
- [(1,'=',1)]
-
+
+ [(1, '=', 1)]
+ Employee Expense
-
- [('employee_id.user_id','=',user.id)]
-
+
+ [('employee_id.user_id', '=', user.id)]
+ Expense multi company rule
-
+
- ['|',('company_id','=',False),('company_id','child_of',[user.company_id.id])]
+ ['|',('company_id', '=', False), ('company_id', 'child_of', [user.company_id.id])]
-
+
diff --git a/addons/hr_expense/test/expense_demo.yml b/addons/hr_expense/test/expense_demo.yml
deleted file mode 100644
index cae5817d9a2..00000000000
--- a/addons/hr_expense/test/expense_demo.yml
+++ /dev/null
@@ -1,8 +0,0 @@
--
- !record {model: hr.expense.expense, id: sep_expenses}:
- employee_id: hr.employee_mit
- name : 'John Smith'
--
- !record {model: hr.expense.line, id: expense_line}:
- product_id: hr_expense.car_travel
- name: 'Car Travel Expenses'
diff --git a/addons/hr_expense/test/expense_process.yml b/addons/hr_expense/test/expense_process.yml
deleted file mode 100644
index b3e19714c74..00000000000
--- a/addons/hr_expense/test/expense_process.yml
+++ /dev/null
@@ -1,34 +0,0 @@
--
- In order to test the process of "Employee Expenses" by processing the expenses, I confirm Expense Bill of an employee for September month.
--
- !workflow {model: hr.expense.expense, action: confirm, ref: sep_expenses}
--
- I check that state is 'Confirm'.
--
- !assert {model: hr.expense.expense, id: sep_expenses, severity: error, string: Expense should be in Confirm state}:
- - state == 'confirm'
--
- I approve the September expenses.
--
- !workflow {model: hr.expense.expense, action: validate, ref: sep_expenses}
--
- I check that state is 'Approved'
--
- !assert {model: hr.expense.expense, id: sep_expenses, severity: error, string: Expense should be in Approved state}:
- - state == 'accepted'
--
- I make Receipt for the expense.
--
- !workflow {model: hr.expense.expense, action: done, ref: sep_expenses}
--
- I check receipt details.
--
- !python {model: hr.expense.expense}: |
- sep_expenses = self.browse(cr, uid, ref("sep_expenses"), context=context)
- assert sep_expenses.state == 'done', "Expense should be in 'Done' state."
--
- I duplicate the expenses and cancel duplicated.
--
- !python {model: hr.expense.expense}: |
- duplicate_id = self.copy(cr, uid, ref('sep_expenses'), context=context)
- self.expense_canceled(cr, uid, [duplicate_id])
diff --git a/addons/hr_expense/tests/__init__.py b/addons/hr_expense/tests/__init__.py
index ddaeb0a7612..e69de29bb2d 100644
--- a/addons/hr_expense/tests/__init__.py
+++ b/addons/hr_expense/tests/__init__.py
@@ -1 +0,0 @@
-from . import test_journal_entries
diff --git a/addons/hr_expense/tests/test_journal_entries.py b/addons/hr_expense/tests/test_journal_entries.py
deleted file mode 100644
index 0a4710d1894..00000000000
--- a/addons/hr_expense/tests/test_journal_entries.py
+++ /dev/null
@@ -1,68 +0,0 @@
-from openerp.tests.common import TransactionCase
-from openerp import workflow
-
-
-class TestCheckJournalEntry(TransactionCase):
- """
- Check journal entries when the expense product is having tax which is tax included.
- """
-
- def setUp(self):
- super(TestCheckJournalEntry, self).setUp()
- cr, uid = self.cr, self.uid
- self.expense_obj = self.registry('hr.expense.expense')
- self.exp_line_obj = self.registry('hr.expense.line')
- self.product_obj = self.registry('product.product')
- self.tax_obj = self.registry('account.tax')
- _, self.product_id = self.registry("ir.model.data").get_object_reference(cr, uid, "hr_expense", "air_ticket")
- self.employee = self.registry("ir.model.data").xmlid_to_object(cr, uid, "hr.employee_mit")
- self.tax_id = self.tax_obj.create(cr, uid, {
- 'name': 'Expense 10%',
- 'amount': 10,
- 'amount_type': 'percent',
- 'type_tax_use': 'purchase',
- 'price_include': True,
- })
- self.product_obj.write(cr, uid, self.product_id, {'supplier_taxes_id': [(6, 0, [self.tax_id])]})
- # Create payable account for the expense
- user_type_id = self.registry('ir.model.data').get_object_reference(cr, uid, 'account', 'data_account_type_payable')[1]
- account_payable_id = self.registry('account.account').create(cr, uid, {'code': 'X1111', 'name': 'HR Expense - Test Payable Account', 'user_type_id': user_type_id, 'reconcile': True})
- # Create expenses account for the expense
- user_type_id = self.registry('ir.model.data').get_object_reference(cr, uid, 'account', 'data_account_type_expenses')[1]
- account_expense_id = self.registry('account.account').create(cr, uid, {'code': 'X2120', 'name': 'HR Expense - Test Purchase Account', 'user_type_id': user_type_id})
- # Assign it to the air ticket product
- self.registry('product.product').write(cr, uid, self.product_id, {'property_account_expense_id': account_expense_id})
- # Create Sale Journal
- company_id = self.registry('ir.model.data').get_object_reference(cr, uid, 'base', 'main_company')[1]
- self.registry('account.journal').create(cr, uid, {'name': 'Purchase Journal - Test', 'code': 'HRTPJ', 'type': 'purchase', 'company_id': company_id})
- self.expense_id = self.expense_obj.create(cr, uid, {
- 'name': 'Expense for Minh Tran',
- 'employee_id': self.employee.id,
- 'employee_payable_account_id': account_payable_id,
- })
- self.exp_line_obj.create(cr, uid, {
- 'name': 'Car Travel Expenses',
- 'product_id': self.product_id,
- 'unit_amount': 700.00,
- 'expense_id': self.expense_id
- })
-
- def test_journal_entry(self):
- cr, uid = self.cr, self.uid
- #Submit to Manager
- workflow.trg_validate(uid, 'hr.expense.expense', self.expense_id, 'confirm', cr)
- #Approve
- workflow.trg_validate(uid, 'hr.expense.expense', self.expense_id, 'validate', cr)
- #Create Expense Entries
- workflow.trg_validate(uid, 'hr.expense.expense', self.expense_id, 'done', cr)
- self.expense = self.expense_obj.browse(cr, uid, self.expense_id)
- self.assertEquals(self.expense.state, 'done', 'Expense is not in Waiting Payment state')
- self.assertTrue(self.expense.account_move_id.id, 'Expense Journal Entry is not created')
- for line in self.expense.account_move_id.line_ids:
- if line.credit:
- self.assertAlmostEquals(line.credit, 700.00)
- else:
- if not line.tax_line_id.id == self.tax_id:
- self.assertAlmostEquals(line.debit, 636.36)
- else:
- self.assertAlmostEquals(line.debit, 63.64)
diff --git a/addons/hr_expense/hr_dashboard.xml b/addons/hr_expense/views/hr_dashboard.xml
similarity index 78%
rename from addons/hr_expense/hr_dashboard.xml
rename to addons/hr_expense/views/hr_dashboard.xml
index 2f0fb24f391..bf4afa7943a 100644
--- a/addons/hr_expense/hr_dashboard.xml
+++ b/addons/hr_expense/views/hr_dashboard.xml
@@ -46,16 +46,4 @@
-
-
-
- Open HR Menu
- reload
-
-
-
-
- open
-
-
-
\ No newline at end of file
+
diff --git a/addons/hr_expense/views/hr_expense_installer_views.xml b/addons/hr_expense/views/hr_expense_installer_views.xml
new file mode 100644
index 00000000000..2dafa16ce95
--- /dev/null
+++ b/addons/hr_expense/views/hr_expense_installer_views.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+ Open Expense Menu
+ reload
+
+
+
+
+
+ open
+
+
+
diff --git a/addons/hr_expense/views/hr_expense_views.xml b/addons/hr_expense/views/hr_expense_views.xml
new file mode 100644
index 00000000000..016b2a60c78
--- /dev/null
+++ b/addons/hr_expense/views/hr_expense_views.xml
@@ -0,0 +1,348 @@
+
+
+
+
+
+
+ hr.expense.tree
+ hr.expense
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ my.hr.expense.tree
+ hr.expense
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ hr.expense.form
+ hr.expense
+
+
+