301 lines
16 KiB
Python
301 lines
16 KiB
Python
# -*- 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='Expense Description', readonly=True, required=True, states={'draft': [('readonly', False)]})
|
|
date = fields.Date(readonly=True, states={'draft': [('readonly', False)]}, default=fields.Date.context_today, string="Date")
|
|
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)], required=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='Analytic Account', 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", "Employee (to reimburse)"), ("company_account", "Company")], default='own_account', states={'done': [('readonly', True)], 'post': [('readonly', True)]}, string="Payment By")
|
|
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), 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:
|
|
if not self.name:
|
|
self.name = 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
|
|
self.tax_ids = self.product_id.supplier_taxes_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.<br/><ul class=o_timeline_tracking_value_list><li>Reason<span> : </span><span class=o_timeline_tracking_value>%s</span></li></ul>") % (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."))
|
|
|
|
if any(not expense.journal_id for expense in self):
|
|
raise UserError(_("Expenses must have an expense journal specified to generate accounting entries."))
|
|
|
|
#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
|