[FIX] account_payment: the amount_residual field is not searchable so it cannot be...
[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']
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', size=128, 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_id': fields.many2one('hr.employee', "Employee", required=True, readonly=True, states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
73         'user_id': fields.many2one('res.users', 'User', required=True),
74         'date_confirm': fields.date('Confirmation Date', select=True, help="Date of the confirmation of the sheet expense. It's filled when the button Confirm is pressed."),
75         'date_valid': fields.date('Validation Date', select=True, help="Date of the acceptation of the sheet expense. It's filled when the button Accept is pressed."),
76         'user_valid': fields.many2one('res.users', 'Validation By', readonly=True, states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
77         'account_move_id': fields.many2one('account.move', 'Ledger Posting'),
78         'line_ids': fields.one2many('hr.expense.line', 'expense_id', 'Expense Lines', readonly=True, states={'draft':[('readonly',False)]} ),
79         'note': fields.text('Note'),
80         'amount': fields.function(_amount, string='Total Amount', digits_compute=dp.get_precision('Account'), 
81             store={
82                 'hr.expense.line': (_get_expense_from_line, ['unit_amount','unit_quantity'], 10)
83             }),
84         'currency_id': fields.many2one('res.currency', 'Currency', required=True, readonly=True, states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
85         'department_id':fields.many2one('hr.department','Department', readonly=True, states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
86         'company_id': fields.many2one('res.company', 'Company', required=True),
87         'state': fields.selection([
88             ('draft', 'New'),
89             ('cancelled', 'Refused'),
90             ('confirm', 'Waiting Approval'),
91             ('accepted', 'Approved'),
92             ('done', 'Waiting Payment'),
93             ('paid', 'Paid'),
94             ],
95             'Status', readonly=True, track_visibility='onchange',
96             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\'.\
97             \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\'.'),
98
99     }
100     _defaults = {
101         'company_id': lambda s, cr, uid, c: s.pool.get('res.company')._company_default_get(cr, uid, 'hr.employee', context=c),
102         'date': fields.date.context_today,
103         'state': 'draft',
104         'employee_id': _employee_get,
105         'user_id': lambda cr, uid, id, c={}: id,
106         'currency_id': _get_currency,
107     }
108
109     def copy(self, cr, uid, id, default=None, context=None):
110         if default is None:
111             default = {}
112         default.update(
113             account_move_id=False,
114             date_confirm=False,
115             date_valid=False,
116             user_valid=False)
117         return super(hr_expense_expense, self).copy(cr, uid, id, default=default, context=context)
118
119     def unlink(self, cr, uid, ids, context=None):
120         for rec in self.browse(cr, uid, ids, context=context):
121             if rec.state != 'draft':
122                 raise osv.except_osv(_('Warning!'),_('You can only delete draft expenses!'))
123         return super(hr_expense_expense, self).unlink(cr, uid, ids, context)
124
125     def onchange_currency_id(self, cr, uid, ids, currency_id=False, company_id=False, context=None):
126         res =  {'value': {'journal_id': False}}
127         journal_ids = self.pool.get('account.journal').search(cr, uid, [('type','=','purchase'), ('currency','=',currency_id), ('company_id', '=', company_id)], context=context)
128         if journal_ids:
129             res['value']['journal_id'] = journal_ids[0]
130         return res
131
132     def onchange_employee_id(self, cr, uid, ids, employee_id, context=None):
133         emp_obj = self.pool.get('hr.employee')
134         department_id = False
135         company_id = False
136         if employee_id:
137             employee = emp_obj.browse(cr, uid, employee_id, context=context)
138             department_id = employee.department_id.id
139             company_id = employee.company_id.id
140         return {'value': {'department_id': department_id, 'company_id': company_id}}
141
142     def expense_confirm(self, cr, uid, ids, context=None):
143         for expense in self.browse(cr, uid, ids):
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         if context is None:
213             context={}
214         context.update({'date': exp.date_confirm or time.strftime('%Y-%m-%d')})
215         total = 0.0
216         total_currency = 0.0
217         for i in account_move_lines:
218             if exp.currency_id.id != company_currency:
219                 i['currency_id'] = exp.currency_id.id
220                 i['amount_currency'] = i['price']
221                 i['price'] = cur_obj.compute(cr, uid, exp.currency_id.id,
222                         company_currency, i['price'],
223                         context=context)
224             else:
225                 i['amount_currency'] = False
226                 i['currency_id'] = False
227             total -= i['price']
228             total_currency -= i['amount_currency'] or i['price']
229         return total, total_currency, account_move_lines
230         
231     def action_move_create(self, cr, uid, ids, context=None):
232         '''
233         main function that is called when trying to create the accounting entries related to an expense
234         '''
235         move_obj = self.pool.get('account.move')
236         for exp in self.browse(cr, uid, ids, context=context):
237             if not exp.employee_id.address_home_id:
238                 raise osv.except_osv(_('Error!'), _('The employee must have a home address.'))
239             if not exp.employee_id.address_home_id.property_account_payable.id:
240                 raise osv.except_osv(_('Error!'), _('The employee must have a payable account set on his home address.'))
241             company_currency = exp.company_id.currency_id.id
242             diff_currency_p = exp.currency_id.id <> company_currency
243             
244             #create the move that will contain the accounting entries
245             move_id = move_obj.create(cr, uid, self.account_move_get(cr, uid, exp.id, context=context), context=context)
246         
247             #one account.move.line per expense line (+taxes..)
248             eml = self.move_line_get(cr, uid, exp.id, context=context)
249             
250             #create one more move line, a counterline for the total on payable account
251             total, total_currency, eml = self.compute_expense_totals(cr, uid, exp, company_currency, exp.name, eml, context=context)
252             acc = exp.employee_id.address_home_id.property_account_payable.id
253             eml.append({
254                     'type': 'dest',
255                     'name': '/',
256                     'price': total, 
257                     'account_id': acc, 
258                     'date_maturity': exp.date_confirm, 
259                     'amount_currency': diff_currency_p and total_currency or False, 
260                     'currency_id': diff_currency_p and exp.currency_id.id or False, 
261                     'ref': exp.name
262                     })
263
264             #convert eml into an osv-valid format
265             lines = map(lambda x:(0,0,self.line_get_convert(cr, uid, x, exp.employee_id.address_home_id, exp.date_confirm, context=context)), eml)
266             move_obj.write(cr, uid, [move_id], {'line_id': lines}, context=context)
267             self.write(cr, uid, ids, {'account_move_id': move_id, 'state': 'done'}, context=context)
268         return True
269
270     def move_line_get(self, cr, uid, expense_id, context=None):
271         res = []
272         tax_obj = self.pool.get('account.tax')
273         cur_obj = self.pool.get('res.currency')
274         if context is None:
275             context = {}
276         exp = self.browse(cr, uid, expense_id, context=context)
277         company_currency = exp.company_id.currency_id.id
278
279         for line in exp.line_ids:
280             mres = self.move_line_get_item(cr, uid, line, context)
281             if not mres:
282                 continue
283             res.append(mres)
284             tax_code_found= False
285             
286             #Calculate tax according to default tax on product
287             taxes = []
288             #Taken from product_id_onchange in account.invoice
289             if line.product_id:
290                 fposition_id = False
291                 fpos_obj = self.pool.get('account.fiscal.position')
292                 fpos = fposition_id and fpos_obj.browse(cr, uid, fposition_id, context=context) or False
293                 product = line.product_id
294                 taxes = product.supplier_taxes_id
295                 #If taxes are not related to the product, maybe they are in the account
296                 if not taxes:
297                     a = product.property_account_expense.id #Why is not there a check here?
298                     if not a:
299                         a = product.categ_id.property_account_expense_categ.id
300                     a = fpos_obj.map_account(cr, uid, fpos, a)
301                     taxes = a and self.pool.get('account.account').browse(cr, uid, a, context=context).tax_ids or False
302                 tax_id = fpos_obj.map_tax(cr, uid, fpos, taxes)
303             if not taxes:
304                 continue
305             #Calculating tax on the line and creating move?
306             for tax in tax_obj.compute_all(cr, uid, taxes,
307                     line.unit_amount ,
308                     line.unit_quantity, line.product_id,
309                     exp.user_id.partner_id)['taxes']:
310                 tax_code_id = tax['base_code_id']
311                 tax_amount = line.total_amount * tax['base_sign']
312                 if tax_code_found:
313                     if not tax_code_id:
314                         continue
315                     res.append(self.move_line_get_item(cr, uid, line, context))
316                     res[-1]['price'] = 0.0
317                     res[-1]['account_analytic_id'] = False
318                 elif not tax_code_id:
319                     continue
320                 tax_code_found = True
321                 res[-1]['tax_code_id'] = tax_code_id
322                 res[-1]['tax_amount'] = cur_obj.compute(cr, uid, exp.currency_id.id, company_currency, tax_amount, context={'date': exp.date_confirm})
323                 ## 
324                 is_price_include = tax_obj.read(cr,uid,tax['id'],['price_include'],context)['price_include']
325                 if is_price_include:
326                     ## We need to deduce the price for the tax
327                     res[-1]['price'] = res[-1]['price']  - (tax['amount'] * tax['base_sign'] or 0.0)
328                 assoc_tax = {
329                              'type':'tax',
330                              'name':tax['name'],
331                              'price_unit': tax['price_unit'],
332                              'quantity': 1,
333                              'price':  tax['amount'] * tax['base_sign'] or 0.0,
334                              'account_id': tax['account_collected_id'] or mres['account_id'],
335                              'tax_code_id': tax['tax_code_id'],
336                              'tax_amount': tax['amount'] * tax['base_sign'],
337                              }
338                 res.append(assoc_tax)
339         return res
340
341     def move_line_get_item(self, cr, uid, line, context=None):
342         company = line.expense_id.company_id
343         property_obj = self.pool.get('ir.property')
344         if line.product_id:
345             acc = line.product_id.property_account_expense
346             if not acc:
347                 acc = line.product_id.categ_id.property_account_expense_categ
348             if not acc:
349                 raise osv.except_osv(_('Error!'), _('No purchase account found for the product %s (or for his category), please configure one.') % (line.product_id.name))
350         else:
351             acc = property_obj.get(cr, uid, 'property_account_expense_categ', 'product.category', context={'force_company': company.id})
352             if not acc:
353                 raise osv.except_osv(_('Error!'), _('Please configure Default Expense account for Product purchase: `property_account_expense_categ`.'))
354         return {
355             'type':'src',
356             'name': line.name.split('\n')[0][:64],
357             'price_unit':line.unit_amount,
358             'quantity':line.unit_quantity,
359             'price':line.total_amount,
360             'account_id':acc.id,
361             'product_id':line.product_id.id,
362             'uos_id':line.uom_id.id,
363             'account_analytic_id':line.analytic_account.id,
364         }
365
366     def action_view_move(self, cr, uid, ids, context=None):
367         '''
368         This function returns an action that display existing account.move of given expense ids.
369         '''
370         assert len(ids) == 1, 'This option should only be used for a single id at a time'
371         expense = self.browse(cr, uid, ids[0], context=context)
372         assert expense.account_move_id
373         try:
374             dummy, view_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'account', 'view_move_form')
375         except ValueError, e:
376             view_id = False
377         result = {
378             'name': _('Expense Account Move'),
379             'view_type': 'form',
380             'view_mode': 'form',
381             'view_id': view_id,
382             'res_model': 'account.move',
383             'type': 'ir.actions.act_window',
384             'nodestroy': True,
385             'target': 'current',
386             'res_id': expense.account_move_id.id,
387         }
388         return result
389
390
391 class product_product(osv.osv):
392     _inherit = "product.product"
393     _columns = {
394         'hr_expense_ok': fields.boolean('Can be Expensed', help="Specify if the product can be selected in an HR expense line."),
395     }
396
397
398 class hr_expense_line(osv.osv):
399     _name = "hr.expense.line"
400     _description = "Expense Line"
401
402     def _amount(self, cr, uid, ids, field_name, arg, context=None):
403         if not ids:
404             return {}
405         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),))
406         res = dict(cr.fetchall())
407         return res
408
409     def _get_uom_id(self, cr, uid, context=None):
410         result = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'product', 'product_uom_unit')
411         return result and result[1] or False
412
413     _columns = {
414         'name': fields.char('Expense Note', size=128, required=True),
415         'date_value': fields.date('Date', required=True),
416         'expense_id': fields.many2one('hr.expense.expense', 'Expense', ondelete='cascade', select=True),
417         'total_amount': fields.function(_amount, string='Total', digits_compute=dp.get_precision('Account')),
418         'unit_amount': fields.float('Unit Price', digits_compute=dp.get_precision('Product Price')),
419         'unit_quantity': fields.float('Quantities', digits_compute= dp.get_precision('Product Unit of Measure')),
420         'product_id': fields.many2one('product.product', 'Product', domain=[('hr_expense_ok','=',True)]),
421         'uom_id': fields.many2one('product.uom', 'Unit of Measure', required=True),
422         'description': fields.text('Description'),
423         'analytic_account': fields.many2one('account.analytic.account','Analytic account'),
424         'ref': fields.char('Reference', size=32),
425         'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of expense lines."),
426         }
427     _defaults = {
428         'unit_quantity': 1,
429         'date_value': lambda *a: time.strftime('%Y-%m-%d'),
430         'uom_id': _get_uom_id,
431     }
432     _order = "sequence, date_value desc"
433
434     def onchange_product_id(self, cr, uid, ids, product_id, context=None):
435         res = {}
436         if product_id:
437             product = self.pool.get('product.product').browse(cr, uid, product_id, context=context)
438             res['name'] = product.name
439             amount_unit = product.price_get('standard_price')[product.id]
440             res['unit_amount'] = amount_unit
441             res['uom_id'] = product.uom_id.id
442         return {'value': res}
443
444     def onchange_uom(self, cr, uid, ids, product_id, uom_id, context=None):
445         res = {'value':{}}
446         if not uom_id or not product_id:
447             return res
448         product = self.pool.get('product.product').browse(cr, uid, product_id, context=context)
449         uom = self.pool.get('product.uom').browse(cr, uid, uom_id, context=context)
450         if uom.category_id.id != product.uom_id.category_id.id:
451             res['warning'] = {'title': _('Warning'), 'message': _('Selected Unit of Measure does not belong to the same category as the product Unit of Measure')}
452             res['value'].update({'uom_id': product.uom_id.id})
453         return res
454
455
456 class account_move_line(osv.osv):
457     _inherit = "account.move.line"
458
459     def reconcile(self, cr, uid, ids, type='auto', writeoff_acc_id=False, writeoff_period_id=False, writeoff_journal_id=False, context=None):
460         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)
461         #when making a full reconciliation of account move lines 'ids', we may need to recompute the state of some hr.expense
462         account_move_ids = [aml.move_id.id for aml in self.browse(cr, uid, ids, context=context)]
463         expense_obj = self.pool.get('hr.expense.expense')
464         currency_obj = self.pool.get('res.currency')
465         if account_move_ids:
466             expense_ids = expense_obj.search(cr, uid, [('account_move_id', 'in', account_move_ids)], context=context)
467             for expense in expense_obj.browse(cr, uid, expense_ids, context=context):
468                 if expense.state == 'done':
469                     #making the postulate it has to be set paid, then trying to invalidate it
470                     new_status_is_paid = True
471                     for aml in expense.account_move_id.line_id:
472                         if aml.account_id.type == 'payable' and not currency_obj.is_zero(cr, uid, expense.company_id.currency_id, aml.amount_residual):
473                             new_status_is_paid = False
474                     if new_status_is_paid:
475                         expense_obj.write(cr, uid, [expense.id], {'state': 'paid'}, context=context)
476         return res
477
478 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: