[FIX] Chatter: fixed suggested recipients, when having no email, unchecking the box...
[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 import netsvc
25 from openerp.osv import fields, osv
26 from openerp.tools.translate import _
27
28 import openerp.addons.decimal_precision as dp
29
30 def _employee_get(obj, cr, uid, context=None):
31     if context is None:
32         context = {}
33     ids = obj.pool.get('hr.employee').search(cr, uid, [('user_id', '=', uid)], context=context)
34     if ids:
35         return ids[0]
36     return False
37
38 class hr_expense_expense(osv.osv):
39
40     def copy(self, cr, uid, id, default=None, context=None):
41         if context is None:
42             context = {}
43         if not default: default = {}
44         default.update({'voucher_id': False, 'date_confirm': False, 'date_valid': False, 'user_valid': False})
45         return super(hr_expense_expense, self).copy(cr, uid, id, default, context=context)
46
47     def _amount(self, cr, uid, ids, field_name, arg, context=None):
48         res= {}
49         for expense in self.browse(cr, uid, ids, context=context):
50             total = 0.0
51             for line in expense.line_ids:
52                 total += line.unit_amount * line.unit_quantity
53             res[expense.id] = total
54         return res
55
56     def _get_currency(self, cr, uid, context=None):
57         user = self.pool.get('res.users').browse(cr, uid, [uid], context=context)[0]
58         return user.company_id.currency_id.id
59
60     _name = "hr.expense.expense"
61     _inherit = ['mail.thread']
62     _description = "Expense"
63     _order = "id desc"
64     _track = {
65         'state': {
66             'hr_expense.mt_expense_approved': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'accepted',
67             'hr_expense.mt_expense_refused': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'cancelled',
68             'hr_expense.mt_expense_confirmed': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'confirm',
69         },
70     }
71
72     _columns = {
73         'name': fields.char('Description', size=128, required=True, readonly=True, states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
74         'id': fields.integer('Sheet ID', readonly=True),
75         'date': fields.date('Date', select=True, readonly=True, states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
76         'journal_id': fields.many2one('account.journal', 'Force Journal', help = "The journal used when the expense is done."),
77         'employee_id': fields.many2one('hr.employee', "Employee", required=True, readonly=True, states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
78         'user_id': fields.many2one('res.users', 'User', required=True),
79         '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."),
80         '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."),
81         'user_valid': fields.many2one('res.users', 'Validation By', readonly=True, states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
82         'account_move_id': fields.many2one('account.move', 'Ledger Posting'),
83         'line_ids': fields.one2many('hr.expense.line', 'expense_id', 'Expense Lines', 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         'voucher_id': fields.many2one('account.voucher', "Employee's Receipt"),
87         'currency_id': fields.many2one('res.currency', 'Currency', required=True, readonly=True, states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
88         'department_id':fields.many2one('hr.department','Department', readonly=True, states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
89         'company_id': fields.many2one('res.company', 'Company', required=True),
90         'state': fields.selection([
91             ('draft', 'New'),
92             ('cancelled', 'Refused'),
93             ('confirm', 'Waiting Approval'),
94             ('accepted', 'Approved'),
95             ('done', 'Waiting Payment'),
96             ('paid', 'Paid'),
97             ],
98             'Status', readonly=True, track_visibility='onchange',
99             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\'.\
100             \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\'.'),
101
102     }
103     _defaults = {
104         'company_id': lambda s, cr, uid, c: s.pool.get('res.company')._company_default_get(cr, uid, 'hr.employee', context=c),
105         'date': fields.date.context_today,
106         'state': 'draft',
107         'employee_id': _employee_get,
108         'user_id': lambda cr, uid, id, c={}: id,
109         'currency_id': _get_currency,
110     }
111
112     def copy(self, cr, uid, id, default=None, context=None):
113         if default is None:
114             default = {}
115         default.update(account_move_id=False)
116         return super(hr_expense_expense, self).copy(cr, uid, id, default=default, context=context)
117
118     def unlink(self, cr, uid, ids, context=None):
119         for rec in self.browse(cr, uid, ids, context=context):
120             if rec.state != 'draft':
121                 raise osv.except_osv(_('Warning!'),_('You can only delete draft expenses!'))
122         return super(hr_expense_expense, self).unlink(cr, uid, ids, context)
123
124     def onchange_currency_id(self, cr, uid, ids, currency_id=False, company_id=False, context=None):
125         res =  {'value': {'journal_id': False}}
126         journal_ids = self.pool.get('account.journal').search(cr, uid, [('type','=','purchase'), ('currency','=',currency_id), ('company_id', '=', company_id)], context=context)
127         if journal_ids:
128             res['value']['journal_id'] = journal_ids[0]
129         return res
130
131     def onchange_employee_id(self, cr, uid, ids, employee_id, context=None):
132         emp_obj = self.pool.get('hr.employee')
133         department_id = False
134         company_id = False
135         if employee_id:
136             employee = emp_obj.browse(cr, uid, employee_id, context=context)
137             department_id = employee.department_id.id
138             company_id = employee.company_id.id
139         return {'value': {'department_id': department_id, 'company_id': company_id}}
140
141     def expense_confirm(self, cr, uid, ids, context=None):
142         for expense in self.browse(cr, uid, ids):
143             if expense.employee_id and expense.employee_id.parent_id.user_id:
144                 self.message_subscribe_users(cr, uid, [expense.id], user_ids=[expense.employee_id.parent_id.user_id.id])
145         return self.write(cr, uid, ids, {'state': 'confirm', 'date_confirm': time.strftime('%Y-%m-%d')}, context=context)
146
147     def expense_accept(self, cr, uid, ids, context=None):
148         return self.write(cr, uid, ids, {'state': 'accepted', 'date_valid': time.strftime('%Y-%m-%d'), 'user_valid': uid}, context=context)
149
150     def expense_canceled(self, cr, uid, ids, context=None):
151         return self.write(cr, uid, ids, {'state': 'cancelled'}, context=context)
152
153     def account_move_get(self, cr, uid, expense_id, context=None):
154         '''
155         This method prepare the creation of the account move related to the given expense.
156
157         :param expense_id: Id of voucher for which we are creating account_move.
158         :return: mapping between fieldname and value of account move to create
159         :rtype: dict
160         '''
161         journal_obj = self.pool.get('account.journal')
162         expense = self.browse(cr, uid, expense_id, context=context)
163         company_id = expense.company_id.id
164         date = expense.date_confirm
165         ref = expense.name
166         journal_id = False
167         if expense.journal_id:
168             journal_id = expense.journal_id.id
169         else:
170             journal_id = journal_obj.search(cr, uid, [('type', '=', 'purchase'), ('company_id', '=', company_id)])
171             if not journal_id:
172                 raise osv.except_osv(_('Error!'), _("No expense journal found. Please make sure you have a journal with type 'purchase' configured."))
173             journal_id = journal_id[0]
174         return self.pool.get('account.move').account_move_prepare(cr, uid, journal_id, date=date, ref=ref, company_id=company_id, context=context)
175
176     def line_get_convert(self, cr, uid, x, part, date, context=None):
177         partner_id  = self.pool.get('res.partner')._find_accounting_partner(part).id
178         return {
179             'date_maturity': x.get('date_maturity', False),
180             'partner_id': partner_id,
181             'name': x['name'][:64],
182             'date': date,
183             'debit': x['price']>0 and x['price'],
184             'credit': x['price']<0 and -x['price'],
185             'account_id': x['account_id'],
186             'analytic_lines': x.get('analytic_lines', False),
187             'amount_currency': x['price']>0 and abs(x.get('amount_currency', False)) or -abs(x.get('amount_currency', False)),
188             'currency_id': x.get('currency_id', False),
189             'tax_code_id': x.get('tax_code_id', False),
190             'tax_amount': x.get('tax_amount', False),
191             'ref': x.get('ref', False),
192             'quantity': x.get('quantity',1.00),
193             'product_id': x.get('product_id', False),
194             'product_uom_id': x.get('uos_id', False),
195             'analytic_account_id': x.get('account_analytic_id', False),
196         }
197
198     def compute_expense_totals(self, cr, uid, exp, company_currency, ref, account_move_lines, context=None):
199         '''
200         internal method used for computation of total amount of an expense in the company currency and
201         in the expense currency, given the account_move_lines that will be created. It also do some small
202         transformations at these account_move_lines (for multi-currency purposes)
203         
204         :param account_move_lines: list of dict
205         :rtype: tuple of 3 elements (a, b ,c)
206             a: total in company currency
207             b: total in hr.expense currency
208             c: account_move_lines potentially modified
209         '''
210         cur_obj = self.pool.get('res.currency')
211         if context is None:
212             context={}
213         context.update({'date': exp.date_confirm or time.strftime('%Y-%m-%d')})
214         total = 0.0
215         total_currency = 0.0
216         for i in account_move_lines:
217             if exp.currency_id.id != company_currency:
218                 i['currency_id'] = exp.currency_id.id
219                 i['amount_currency'] = i['price']
220                 i['price'] = cur_obj.compute(cr, uid, exp.currency_id.id,
221                         company_currency, i['price'],
222                         context=context)
223             else:
224                 i['amount_currency'] = False
225                 i['currency_id'] = False
226             total -= i['price']
227             total_currency -= i['amount_currency'] or i['price']
228         return total, total_currency, account_move_lines
229
230
231     def action_receipt_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_receipt(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 hr_expense_expense()
391
392 class product_product(osv.osv):
393     _inherit = "product.product"
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 product_product()
399
400 class hr_expense_line(osv.osv):
401     _name = "hr.expense.line"
402     _description = "Expense Line"
403
404     def _amount(self, cr, uid, ids, field_name, arg, context=None):
405         if not ids:
406             return {}
407         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),))
408         res = dict(cr.fetchall())
409         return res
410
411     def _get_uom_id(self, cr, uid, context=None):
412         result = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'product', 'product_uom_unit')
413         return result and result[1] or False
414
415     _columns = {
416         'name': fields.char('Expense Note', size=128, required=True),
417         'date_value': fields.date('Date', required=True),
418         'expense_id': fields.many2one('hr.expense.expense', 'Expense', ondelete='cascade', select=True),
419         'total_amount': fields.function(_amount, string='Total', digits_compute=dp.get_precision('Account')),
420         'unit_amount': fields.float('Unit Price', digits_compute=dp.get_precision('Product Price')),
421         'unit_quantity': fields.float('Quantities', digits_compute= dp.get_precision('Product Unit of Measure')),
422         'product_id': fields.many2one('product.product', 'Product', domain=[('hr_expense_ok','=',True)]),
423         'uom_id': fields.many2one('product.uom', 'Unit of Measure', required=True),
424         'description': fields.text('Description'),
425         'analytic_account': fields.many2one('account.analytic.account','Analytic account'),
426         'ref': fields.char('Reference', size=32),
427         'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of expense lines."),
428         }
429     _defaults = {
430         'unit_quantity': 1,
431         'date_value': lambda *a: time.strftime('%Y-%m-%d'),
432         'uom_id': _get_uom_id,
433     }
434     _order = "sequence, date_value desc"
435
436     def onchange_product_id(self, cr, uid, ids, product_id, context=None):
437         res = {}
438         if product_id:
439             product = self.pool.get('product.product').browse(cr, uid, product_id, context=context)
440             res['name'] = product.name
441             amount_unit = product.price_get('standard_price')[product.id]
442             res['unit_amount'] = amount_unit
443             res['uom_id'] = product.uom_id.id
444         return {'value': res}
445
446     def onchange_uom(self, cr, uid, ids, product_id, uom_id, context=None):
447         res = {'value':{}}
448         if not uom_id or not product_id:
449             return res
450         product = self.pool.get('product.product').browse(cr, uid, product_id, context=context)
451         uom = self.pool.get('product.uom').browse(cr, uid, uom_id, context=context)
452         if uom.category_id.id != product.uom_id.category_id.id:
453             res['warning'] = {'title': _('Warning'), 'message': _('Selected Unit of Measure does not belong to the same category as the product Unit of Measure')}
454             res['value'].update({'uom_id': product.uom_id.id})
455         return res
456
457 hr_expense_line()
458
459 class account_move_line(osv.osv):
460     _inherit = "account.move.line"
461
462     def reconcile(self, cr, uid, ids, type='auto', writeoff_acc_id=False, writeoff_period_id=False, writeoff_journal_id=False, context=None):
463         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)
464         #when making a full reconciliation of account move lines 'ids', we may need to recompute the state of some hr.expense
465         account_move_ids = [aml.move_id.id for aml in self.browse(cr, uid, ids, context=context)]
466         expense_obj = self.pool.get('hr.expense.expense')
467         currency_obj = self.pool.get('res.currency')
468         if account_move_ids:
469             expense_ids = expense_obj.search(cr, uid, [('account_move_id', 'in', account_move_ids)], context=context)
470             for expense in expense_obj.browse(cr, uid, expense_ids, context=context):
471                 if expense.state == 'done':
472                     #making the postulate it has to be set paid, then trying to invalidate it
473                     new_status_is_paid = True
474                     for aml in expense.account_move_id.line_id:
475                         if aml.account_id.type == 'payable' and not currency_obj.is_zero(cr, uid, expense.company_id.currency_id, aml.amount_residual):
476                             new_status_is_paid = False
477                     if new_status_is_paid:
478                         expense_obj.write(cr, uid, [expense.id], {'state': 'paid'}, context=context)
479         return res
480
481 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: