[MERGE] forward port of branch saas-3 up to fdc6271
[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             journal_id = move_obj.browse(cr, uid, move_id, context).journal_id
267             # post the journal entry if 'Skip 'Draft' State for Manual Entries' is checked
268             if journal_id.entry_posted:
269                 move_obj.button_validate(cr, uid, [move_id], context)
270             move_obj.write(cr, uid, [move_id], {'line_id': lines}, context=context)
271             self.write(cr, uid, ids, {'account_move_id': move_id, 'state': 'done'}, context=context)
272         return True
273
274     def move_line_get(self, cr, uid, expense_id, context=None):
275         res = []
276         tax_obj = self.pool.get('account.tax')
277         cur_obj = self.pool.get('res.currency')
278         if context is None:
279             context = {}
280         exp = self.browse(cr, uid, expense_id, context=context)
281         company_currency = exp.company_id.currency_id.id
282
283         for line in exp.line_ids:
284             mres = self.move_line_get_item(cr, uid, line, context)
285             if not mres:
286                 continue
287             res.append(mres)
288             
289             #Calculate tax according to default tax on product
290             taxes = []
291             #Taken from product_id_onchange in account.invoice
292             if line.product_id:
293                 fposition_id = False
294                 fpos_obj = self.pool.get('account.fiscal.position')
295                 fpos = fposition_id and fpos_obj.browse(cr, uid, fposition_id, context=context) or False
296                 product = line.product_id
297                 taxes = product.supplier_taxes_id
298                 #If taxes are not related to the product, maybe they are in the account
299                 if not taxes:
300                     a = product.property_account_expense.id #Why is not there a check here?
301                     if not a:
302                         a = product.categ_id.property_account_expense_categ.id
303                     a = fpos_obj.map_account(cr, uid, fpos, a)
304                     taxes = a and self.pool.get('account.account').browse(cr, uid, a, context=context).tax_ids or False
305             if not taxes:
306                 continue
307             tax_l = []
308             #Calculating tax on the line and creating move?
309             for tax in tax_obj.compute_all(cr, uid, taxes,
310                     line.unit_amount ,
311                     line.unit_quantity, line.product_id,
312                     exp.user_id.partner_id)['taxes']:
313                 tax_code_id = tax['base_code_id']
314                 if not tax_code_id:
315                     continue
316                 res[-1]['tax_code_id'] = tax_code_id
317                 ## 
318                 is_price_include = tax_obj.read(cr,uid,tax['id'],['price_include'],context)['price_include']
319                 if is_price_include:
320                     ## We need to deduce the price for the tax
321                     res[-1]['price'] = res[-1]['price']  - (tax['amount'] * tax['base_sign'] or 0.0)
322                     # tax amount countains base amount without the tax
323                     tax_amount = (line.total_amount - tax['amount']) * tax['base_sign']
324                 else:
325                     tax_amount = line.total_amount * tax['base_sign']
326                 res[-1]['tax_amount'] = cur_obj.compute(cr, uid, exp.currency_id.id, company_currency, tax_amount, context={'date': exp.date_confirm})
327                 assoc_tax = {
328                              'type':'tax',
329                              'name':tax['name'],
330                              'price_unit': tax['price_unit'],
331                              'quantity': 1,
332                              'price':  tax['amount'] * tax['base_sign'] or 0.0,
333                              'account_id': tax['account_collected_id'] or mres['account_id'],
334                              'tax_code_id': tax['tax_code_id'],
335                              'tax_amount': tax['amount'] * tax['base_sign'],
336                              }
337                 tax_l.append(assoc_tax)
338             res += tax_l
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_template(osv.osv):
392     _inherit = "product.template"
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: