[MERGE] forward port of branch 8.0 up to e883193
[odoo/odoo.git] / addons / hr_expense / hr_expense.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2004-2010 Tiny SPRL (<http://tiny.be>).
6 #
7 #    This program is free software: you can redistribute it and/or modify
8 #    it under the terms of the GNU Affero General Public License as
9 #    published by the Free Software Foundation, either version 3 of the
10 #    License, or (at your option) any later version.
11 #
12 #    This program is distributed in the hope that it will be useful,
13 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
14 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 #    GNU Affero General Public License for more details.
16 #
17 #    You should have received a copy of the GNU Affero General Public License
18 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
19 #
20 ##############################################################################
21
22 import time
23
24 from openerp.osv import fields, osv
25 from openerp.tools.translate import _
26
27 import openerp.addons.decimal_precision as dp
28
29 def _employee_get(obj, cr, uid, context=None):
30     if context is None:
31         context = {}
32     ids = obj.pool.get('hr.employee').search(cr, uid, [('user_id', '=', uid)], context=context)
33     if ids:
34         return ids[0]
35     return False
36
37 class hr_expense_expense(osv.osv):
38
39     def _amount(self, cr, uid, ids, field_name, arg, context=None):
40         res= {}
41         for expense in self.browse(cr, uid, ids, context=context):
42             total = 0.0
43             for line in expense.line_ids:
44                 total += line.unit_amount * line.unit_quantity
45             res[expense.id] = total
46         return res
47
48     def _get_expense_from_line(self, cr, uid, ids, context=None):
49         return [line.expense_id.id for line in self.pool.get('hr.expense.line').browse(cr, uid, ids, context=context)]
50
51     def _get_currency(self, cr, uid, context=None):
52         user = self.pool.get('res.users').browse(cr, uid, [uid], context=context)[0]
53         return user.company_id.currency_id.id
54
55     _name = "hr.expense.expense"
56     _inherit = ['mail.thread', 'ir.needaction_mixin']
57     _description = "Expense"
58     _order = "id desc"
59     _track = {
60         'state': {
61             'hr_expense.mt_expense_approved': lambda self, cr, uid, obj, ctx=None: obj.state == 'accepted',
62             'hr_expense.mt_expense_refused': lambda self, cr, uid, obj, ctx=None: obj.state == 'cancelled',
63             'hr_expense.mt_expense_confirmed': lambda self, cr, uid, obj, ctx=None: obj.state == 'confirm',
64         },
65     }
66
67     _columns = {
68         'name': fields.char('Description', required=True, readonly=True, states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
69         'id': fields.integer('Sheet ID', readonly=True),
70         'date': fields.date('Date', select=True, readonly=True, states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
71         'journal_id': fields.many2one('account.journal', 'Force Journal', help = "The journal used when the expense is done."),
72         'employee_payable_account_id': fields.many2one('account.account', 'Employee Account', help="Employee payable account"),
73         'employee_id': fields.many2one('hr.employee', "Employee", required=True, readonly=True, states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
74         'user_id': fields.many2one('res.users', 'User', required=True),
75         'date_confirm': fields.date('Confirmation Date', select=True, copy=False,
76                                     help="Date of the confirmation of the sheet expense. It's filled when the button Confirm is pressed."),
77         'date_valid': fields.date('Validation Date', select=True, copy=False,
78                                   help="Date of the acceptation of the sheet expense. It's filled when the button Accept is pressed."),
79         'user_valid': fields.many2one('res.users', 'Validation By', readonly=True, copy=False,
80                                       states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
81         'account_move_id': fields.many2one('account.move', 'Ledger Posting', copy=False, track_visibility="onchange"),
82         'line_ids': fields.one2many('hr.expense.line', 'expense_id', 'Expense Lines', copy=True,
83                                     readonly=True, states={'draft':[('readonly',False)]} ),
84         'note': fields.text('Note'),
85         'amount': fields.function(_amount, string='Total Amount', digits_compute=dp.get_precision('Account'), 
86             store={
87                 'hr.expense.line': (_get_expense_from_line, ['unit_amount','unit_quantity'], 10)
88             }),
89         'currency_id': fields.many2one('res.currency', 'Currency', required=True, readonly=True, states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
90         'department_id':fields.many2one('hr.department','Department', readonly=True, states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
91         'company_id': fields.many2one('res.company', 'Company', required=True),
92         'state': fields.selection([
93             ('draft', 'New'),
94             ('cancelled', 'Refused'),
95             ('confirm', 'Waiting Approval'),
96             ('accepted', 'Approved'),
97             ('done', 'Waiting Payment'),
98             ('paid', 'Paid'),
99             ],
100             'Status', readonly=True, track_visibility='onchange', copy=False,
101             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\'.\
102             \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\'.'),
103
104     }
105     _defaults = {
106         'company_id': lambda s, cr, uid, c: s.pool.get('res.company')._company_default_get(cr, uid, 'hr.employee', context=c),
107         'date': fields.date.context_today,
108         'state': 'draft',
109         'employee_id': _employee_get,
110         'user_id': lambda cr, uid, id, c={}: id,
111         'currency_id': _get_currency,
112     }
113
114     def unlink(self, cr, uid, ids, context=None):
115         for rec in self.browse(cr, uid, ids, context=context):
116             if rec.state != 'draft':
117                 raise osv.except_osv(_('Warning!'),_('You can only delete draft expenses!'))
118         return super(hr_expense_expense, self).unlink(cr, uid, ids, context)
119
120     def onchange_currency_id(self, cr, uid, ids, currency_id=False, company_id=False, context=None):
121         res =  {'value': {'journal_id': False}}
122         journal_ids = self.pool.get('account.journal').search(cr, uid, [('type','=','purchase'), ('currency','=',currency_id), ('company_id', '=', company_id)], context=context)
123         if journal_ids:
124             res['value']['journal_id'] = journal_ids[0]
125         return res
126
127     def onchange_employee_id(self, cr, uid, ids, employee_id, context=None):
128         emp_obj = self.pool.get('hr.employee')
129         department_id = False
130         employee_payable_account_id = False
131         company_id = False
132         if employee_id:
133             employee = emp_obj.browse(cr, uid, employee_id, context=context)
134             department_id = employee.department_id.id
135             company_id = employee.company_id.id
136             if employee.address_home_id and employee.address_home_id.property_account_payable:
137                 employee_payable_account_id = employee.address_home_id.property_account_payable.id
138         return {'value': {'department_id': department_id, 'company_id': company_id, 'employee_payable_account_id': employee_payable_account_id}}
139
140     def expense_confirm(self, cr, uid, ids, context=None):
141         for expense in self.browse(cr, uid, ids):
142             if not expense.line_ids:
143                 raise osv.except_osv(_('Error!'), _('You cannot submit expense which has no expense line.'))
144             if expense.employee_id and expense.employee_id.parent_id.user_id:
145                 self.message_subscribe_users(cr, uid, [expense.id], user_ids=[expense.employee_id.parent_id.user_id.id])
146         return self.write(cr, uid, ids, {'state': 'confirm', 'date_confirm': time.strftime('%Y-%m-%d')}, context=context)
147
148     def expense_accept(self, cr, uid, ids, context=None):
149         return self.write(cr, uid, ids, {'state': 'accepted', 'date_valid': time.strftime('%Y-%m-%d'), 'user_valid': uid}, context=context)
150
151     def expense_canceled(self, cr, uid, ids, context=None):
152         return self.write(cr, uid, ids, {'state': 'cancelled'}, context=context)
153
154     def account_move_get(self, cr, uid, expense_id, context=None):
155         '''
156         This method prepare the creation of the account move related to the given expense.
157
158         :param expense_id: Id of expense for which we are creating account_move.
159         :return: mapping between fieldname and value of account move to create
160         :rtype: dict
161         '''
162         journal_obj = self.pool.get('account.journal')
163         expense = self.browse(cr, uid, expense_id, context=context)
164         company_id = expense.company_id.id
165         date = expense.date_confirm
166         ref = expense.name
167         journal_id = False
168         if expense.journal_id:
169             journal_id = expense.journal_id.id
170         else:
171             journal_id = journal_obj.search(cr, uid, [('type', '=', 'purchase'), ('company_id', '=', company_id)])
172             if not journal_id:
173                 raise osv.except_osv(_('Error!'), _("No expense journal found. Please make sure you have a journal with type 'purchase' configured."))
174             journal_id = journal_id[0]
175         return self.pool.get('account.move').account_move_prepare(cr, uid, journal_id, date=date, ref=ref, company_id=company_id, context=context)
176
177     def line_get_convert(self, cr, uid, x, part, date, context=None):
178         partner_id  = self.pool.get('res.partner')._find_accounting_partner(part).id
179         return {
180             'date_maturity': x.get('date_maturity', False),
181             'partner_id': partner_id,
182             'name': x['name'][:64],
183             'date': date,
184             'debit': x['price']>0 and x['price'],
185             'credit': x['price']<0 and -x['price'],
186             'account_id': x['account_id'],
187             'analytic_lines': x.get('analytic_lines', False),
188             'amount_currency': x['price']>0 and abs(x.get('amount_currency', False)) or -abs(x.get('amount_currency', False)),
189             'currency_id': x.get('currency_id', False),
190             'tax_code_id': x.get('tax_code_id', False),
191             'tax_amount': x.get('tax_amount', False),
192             'ref': x.get('ref', False),
193             'quantity': x.get('quantity',1.00),
194             'product_id': x.get('product_id', False),
195             'product_uom_id': x.get('uos_id', False),
196             'analytic_account_id': x.get('account_analytic_id', False),
197         }
198
199     def compute_expense_totals(self, cr, uid, exp, company_currency, ref, account_move_lines, context=None):
200         '''
201         internal method used for computation of total amount of an expense in the company currency and
202         in the expense currency, given the account_move_lines that will be created. It also do some small
203         transformations at these account_move_lines (for multi-currency purposes)
204         
205         :param account_move_lines: list of dict
206         :rtype: tuple of 3 elements (a, b ,c)
207             a: total in company currency
208             b: total in hr.expense currency
209             c: account_move_lines potentially modified
210         '''
211         cur_obj = self.pool.get('res.currency')
212         context = dict(context or {}, date=exp.date_confirm or time.strftime('%Y-%m-%d'))
213         total = 0.0
214         total_currency = 0.0
215         for i in account_move_lines:
216             if exp.currency_id.id != company_currency:
217                 i['currency_id'] = exp.currency_id.id
218                 i['amount_currency'] = i['price']
219                 i['price'] = cur_obj.compute(cr, uid, exp.currency_id.id,
220                         company_currency, i['price'],
221                         context=context)
222             else:
223                 i['amount_currency'] = False
224                 i['currency_id'] = False
225             total -= i['price']
226             total_currency -= i['amount_currency'] or i['price']
227         return total, total_currency, account_move_lines
228         
229     def action_move_create(self, cr, uid, ids, context=None):
230         '''
231         main function that is called when trying to create the accounting entries related to an expense
232         '''
233         move_obj = self.pool.get('account.move')
234         for exp in self.browse(cr, uid, ids, context=context):
235             if not exp.employee_payable_account_id:
236                 raise osv.except_osv(_('Error!'), _('No employee account payable found for the expense '))
237
238             company_currency = exp.company_id.currency_id.id
239             diff_currency_p = exp.currency_id.id != company_currency
240
241             #create the move that will contain the accounting entries
242             move_id = move_obj.create(cr, uid, self.account_move_get(cr, uid, exp.id, context=context), context=context)
243
244             #one account.move.line per expense line (+taxes..)
245             eml = self.move_line_get(cr, uid, exp.id, context=context)
246
247             #create one more move line, a counterline for the total on payable account
248             total, total_currency, eml = self.compute_expense_totals(cr, uid, exp, company_currency, exp.name, eml, context=context)
249
250             acc = exp.employee_payable_account_id.id or False
251             eml.append({
252                     'type': 'dest',
253                     'name': '/',
254                     'price': total, 
255                     'account_id': acc, 
256                     'date_maturity': exp.date_confirm, 
257                     'amount_currency': diff_currency_p and total_currency or False, 
258                     'currency_id': diff_currency_p and exp.currency_id.id or False, 
259                     'ref': exp.name
260                     })
261
262             #convert eml into an osv-valid format
263             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)
264             journal_id = move_obj.browse(cr, uid, move_id, context).journal_id
265             # post the journal entry if 'Skip 'Draft' State for Manual Entries' is checked
266             if journal_id.entry_posted:
267                 move_obj.button_validate(cr, uid, [move_id], context)
268             move_obj.write(cr, uid, [move_id], {'line_id': lines}, context=context)
269             self.write(cr, uid, ids, {'account_move_id': move_id, 'state': 'done'}, context=context)
270         return True
271
272     def move_line_get(self, cr, uid, expense_id, context=None):
273         res = []
274         tax_obj = self.pool.get('account.tax')
275         cur_obj = self.pool.get('res.currency')
276         if context is None:
277             context = {}
278         exp = self.browse(cr, uid, expense_id, context=context)
279         company_currency = exp.company_id.currency_id.id
280
281         for line in exp.line_ids:
282             mres = self.move_line_get_item(cr, uid, line, context)
283             if not mres:
284                 continue
285             res.append(mres)
286             
287             #Calculate tax according to default tax on product
288             taxes = []
289             #Taken from product_id_onchange in account.invoice
290             if line.product_id:
291                 fposition_id = False
292                 fpos_obj = self.pool.get('account.fiscal.position')
293                 fpos = fposition_id and fpos_obj.browse(cr, uid, fposition_id, context=context) or False
294                 product = line.product_id
295                 taxes = product.supplier_taxes_id
296                 #If taxes are not related to the product, maybe they are in the account
297                 if not taxes:
298                     a = product.property_account_expense.id #Why is not there a check here?
299                     if not a:
300                         a = product.categ_id.property_account_expense_categ.id
301                     a = fpos_obj.map_account(cr, uid, fpos, a)
302                     taxes = a and self.pool.get('account.account').browse(cr, uid, a, context=context).tax_ids or False
303             if not taxes:
304                 continue
305             tax_l = []
306             base_tax_amount = line.total_amount
307             #Calculating tax on the line and creating move?
308             for tax in tax_obj.compute_all(cr, uid, taxes,
309                     line.unit_amount ,
310                     line.unit_quantity, line.product_id,
311                     exp.user_id.partner_id)['taxes']:
312                 tax_code_id = tax['base_code_id']
313                 if not tax_code_id:
314                     continue
315                 res[-1]['tax_code_id'] = tax_code_id
316                 ## 
317                 is_price_include = tax_obj.read(cr,uid,tax['id'],['price_include'],context)['price_include']
318                 if is_price_include:
319                     ## We need to deduce the price for the tax
320                     res[-1]['price'] = res[-1]['price']  - (tax['amount'] * tax['base_sign'] or 0.0)
321                     # tax amount countains base amount without the tax
322                     base_tax_amount = (base_tax_amount - tax['amount']) * tax['base_sign']
323                 else:
324                     base_tax_amount = base_tax_amount * tax['base_sign']
325
326                 assoc_tax = {
327                              'type':'tax',
328                              'name':tax['name'],
329                              'price_unit': tax['price_unit'],
330                              'quantity': 1,
331                              'price':  tax['amount'] * tax['base_sign'] or 0.0,
332                              'account_id': tax['account_collected_id'] or mres['account_id'],
333                              'tax_code_id': tax['tax_code_id'],
334                              'tax_amount': tax['amount'] * tax['base_sign'],
335                              }
336                 tax_l.append(assoc_tax)
337
338             res[-1]['tax_amount'] = cur_obj.compute(cr, uid, exp.currency_id.id, company_currency, base_tax_amount, context={'date': exp.date_confirm})
339             res += tax_l
340         return res
341
342     def move_line_get_item(self, cr, uid, line, context=None):
343         company = line.expense_id.company_id
344         property_obj = self.pool.get('ir.property')
345         if line.product_id:
346             acc = line.product_id.property_account_expense
347             if not acc:
348                 acc = line.product_id.categ_id.property_account_expense_categ
349             if not acc:
350                 raise osv.except_osv(_('Error!'), _('No purchase account found for the product %s (or for his category), please configure one.') % (line.product_id.name))
351         else:
352             acc = property_obj.get(cr, uid, 'property_account_expense_categ', 'product.category', context={'force_company': company.id})
353             if not acc:
354                 raise osv.except_osv(_('Error!'), _('Please configure Default Expense account for Product purchase: `property_account_expense_categ`.'))
355         return {
356             'type':'src',
357             'name': line.name.split('\n')[0][:64],
358             'price_unit':line.unit_amount,
359             'quantity':line.unit_quantity,
360             'price':line.total_amount,
361             'account_id':acc.id,
362             'product_id':line.product_id.id,
363             'uos_id':line.uom_id.id,
364             'account_analytic_id':line.analytic_account.id,
365         }
366
367     def action_view_move(self, cr, uid, ids, context=None):
368         '''
369         This function returns an action that display existing account.move of given expense ids.
370         '''
371         assert len(ids) == 1, 'This option should only be used for a single id at a time'
372         expense = self.browse(cr, uid, ids[0], context=context)
373         assert expense.account_move_id
374         try:
375             dummy, view_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'account', 'view_move_form')
376         except ValueError, e:
377             view_id = False
378         result = {
379             'name': _('Expense Account Move'),
380             'view_type': 'form',
381             'view_mode': 'form',
382             'view_id': view_id,
383             'res_model': 'account.move',
384             'type': 'ir.actions.act_window',
385             'nodestroy': True,
386             'target': 'current',
387             'res_id': expense.account_move_id.id,
388         }
389         return result
390
391
392 class product_template(osv.osv):
393     _inherit = "product.template"
394     _columns = {
395         'hr_expense_ok': fields.boolean('Can be Expensed', help="Specify if the product can be selected in an HR expense line."),
396     }
397
398
399 class hr_expense_line(osv.osv):
400     _name = "hr.expense.line"
401     _description = "Expense Line"
402
403     def _amount(self, cr, uid, ids, field_name, arg, context=None):
404         if not ids:
405             return {}
406         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),))
407         res = dict(cr.fetchall())
408         return res
409
410     def _get_uom_id(self, cr, uid, context=None):
411         result = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'product', 'product_uom_unit')
412         return result and result[1] or False
413
414     _columns = {
415         'name': fields.char('Expense Note', required=True),
416         'date_value': fields.date('Date', required=True),
417         'expense_id': fields.many2one('hr.expense.expense', 'Expense', ondelete='cascade', select=True),
418         'total_amount': fields.function(_amount, string='Total', digits_compute=dp.get_precision('Account')),
419         'unit_amount': fields.float('Unit Price', digits_compute=dp.get_precision('Product Price')),
420         'unit_quantity': fields.float('Quantities', digits_compute= dp.get_precision('Product Unit of Measure')),
421         'product_id': fields.many2one('product.product', 'Product', domain=[('hr_expense_ok','=',True)]),
422         'uom_id': fields.many2one('product.uom', 'Unit of Measure', required=True),
423         'description': fields.text('Description'),
424         'analytic_account': fields.many2one('account.analytic.account','Analytic account'),
425         'ref': fields.char('Reference'),
426         'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of expense lines."),
427         }
428     _defaults = {
429         'unit_quantity': 1,
430         'date_value': lambda *a: time.strftime('%Y-%m-%d'),
431         'uom_id': _get_uom_id,
432     }
433     _order = "sequence, date_value desc"
434
435     def onchange_product_id(self, cr, uid, ids, product_id, context=None):
436         res = {}
437         if product_id:
438             product = self.pool.get('product.product').browse(cr, uid, product_id, context=context)
439             res['name'] = product.name
440             amount_unit = product.price_get('standard_price')[product.id]
441             res['unit_amount'] = amount_unit
442             res['uom_id'] = product.uom_id.id
443         return {'value': res}
444
445     def onchange_uom(self, cr, uid, ids, product_id, uom_id, context=None):
446         res = {'value':{}}
447         if not uom_id or not product_id:
448             return res
449         product = self.pool.get('product.product').browse(cr, uid, product_id, context=context)
450         uom = self.pool.get('product.uom').browse(cr, uid, uom_id, context=context)
451         if uom.category_id.id != product.uom_id.category_id.id:
452             res['warning'] = {'title': _('Warning'), 'message': _('Selected Unit of Measure does not belong to the same category as the product Unit of Measure')}
453             res['value'].update({'uom_id': product.uom_id.id})
454         return res
455
456
457 class account_move_line(osv.osv):
458     _inherit = "account.move.line"
459
460     def reconcile(self, cr, uid, ids, type='auto', writeoff_acc_id=False, writeoff_period_id=False, writeoff_journal_id=False, context=None):
461         res = super(account_move_line, self).reconcile(cr, uid, ids, type=type, writeoff_acc_id=writeoff_acc_id, writeoff_period_id=writeoff_period_id, writeoff_journal_id=writeoff_journal_id, context=context)
462         #when making a full reconciliation of account move lines 'ids', we may need to recompute the state of some hr.expense
463         account_move_ids = [aml.move_id.id for aml in self.browse(cr, uid, ids, context=context)]
464         expense_obj = self.pool.get('hr.expense.expense')
465         currency_obj = self.pool.get('res.currency')
466         if account_move_ids:
467             expense_ids = expense_obj.search(cr, uid, [('account_move_id', 'in', account_move_ids)], context=context)
468             for expense in expense_obj.browse(cr, uid, expense_ids, context=context):
469                 if expense.state == 'done':
470                     #making the postulate it has to be set paid, then trying to invalidate it
471                     new_status_is_paid = True
472                     for aml in expense.account_move_id.line_id:
473                         if aml.account_id.type == 'payable' and not currency_obj.is_zero(cr, uid, expense.company_id.currency_id, aml.amount_residual):
474                             new_status_is_paid = False
475                     if new_status_is_paid:
476                         expense_obj.write(cr, uid, [expense.id], {'state': 'paid'}, context=context)
477         return res
478
479 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: