[MERGE] [FORWARD] Forward port of addons 7.0 until revision 9008
[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 copy(self, cr, uid, id, default=None, context=None):
40         if context is None:
41             context = {}
42         if not default: default = {}
43         default.update({'date_confirm': False, 'date_valid': False, 'user_valid': False})
44         return super(hr_expense_expense, self).copy(cr, uid, id, default, context=context)
45
46     def _amount(self, cr, uid, ids, field_name, arg, context=None):
47         res= {}
48         for expense in self.browse(cr, uid, ids, context=context):
49             total = 0.0
50             for line in expense.line_ids:
51                 total += line.unit_amount * line.unit_quantity
52             res[expense.id] = total
53         return res
54
55     def _get_currency(self, cr, uid, context=None):
56         user = self.pool.get('res.users').browse(cr, uid, [uid], context=context)[0]
57         if user.company_id:
58             return user.company_id.currency_id.id
59         else:
60             return self.pool.get('res.currency').search(cr, uid, [('rate','=',1.0)], context=context)[0]
61
62     _name = "hr.expense.expense"
63     _inherit = ['mail.thread']
64     _description = "Expense"
65     _order = "id desc"
66     _track = {
67         'state': {
68             'hr_expense.mt_expense_approved': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'accepted',
69             'hr_expense.mt_expense_refused': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'cancelled',
70             'hr_expense.mt_expense_confirmed': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'confirm',
71         },
72     }
73
74     _columns = {
75         'name': fields.char('Description', size=128, required=True, readonly=True, states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
76         'id': fields.integer('Sheet ID', readonly=True),
77         'date': fields.date('Date', select=True, readonly=True, states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
78         'journal_id': fields.many2one('account.journal', 'Force Journal', help = "The journal used when the expense is done."),
79         'employee_id': fields.many2one('hr.employee', "Employee", required=True, readonly=True, states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
80         'user_id': fields.many2one('res.users', 'User', required=True),
81         '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."),
82         '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."),
83         'user_valid': fields.many2one('res.users', 'Validation By', readonly=True, states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
84         'account_move_id': fields.many2one('account.move', 'Ledger Posting'),
85         'line_ids': fields.one2many('hr.expense.line', 'expense_id', 'Expense Lines', readonly=True, states={'draft':[('readonly',False)]} ),
86         'note': fields.text('Note'),
87         'amount': fields.function(_amount, string='Total Amount', digits_compute=dp.get_precision('Account')),
88         'currency_id': fields.many2one('res.currency', 'Currency', required=True, readonly=True, states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
89         'department_id':fields.many2one('hr.department','Department', readonly=True, states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
90         'company_id': fields.many2one('res.company', 'Company', required=True),
91         'state': fields.selection([
92             ('draft', 'New'),
93             ('cancelled', 'Refused'),
94             ('confirm', 'Waiting Approval'),
95             ('accepted', 'Approved'),
96             ('done', 'Done'),
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 a receipt is made for the expense request, the status is \'Done\'.'),
101     }
102     _defaults = {
103         'company_id': lambda s, cr, uid, c: s.pool.get('res.company')._company_default_get(cr, uid, 'hr.employee', context=c),
104         'date': fields.date.context_today,
105         'state': 'draft',
106         'employee_id': _employee_get,
107         'user_id': lambda cr, uid, id, c={}: id,
108         'currency_id': _get_currency,
109     }
110
111     def unlink(self, cr, uid, ids, context=None):
112         for rec in self.browse(cr, uid, ids, context=context):
113             if rec.state != 'draft':
114                 raise osv.except_osv(_('Warning!'),_('You can only delete draft expenses!'))
115         return super(hr_expense_expense, self).unlink(cr, uid, ids, context)
116
117     def onchange_currency_id(self, cr, uid, ids, currency_id=False, company_id=False, context=None):
118         res =  {'value': {'journal_id': False}}
119         journal_ids = self.pool.get('account.journal').search(cr, uid, [('type','=','purchase'), ('currency','=',currency_id), ('company_id', '=', company_id)], context=context)
120         if journal_ids:
121             res['value']['journal_id'] = journal_ids[0]
122         return res
123
124     def onchange_employee_id(self, cr, uid, ids, employee_id, context=None):
125         emp_obj = self.pool.get('hr.employee')
126         department_id = False
127         company_id = False
128         if employee_id:
129             employee = emp_obj.browse(cr, uid, employee_id, context=context)
130             department_id = employee.department_id.id
131             company_id = employee.company_id.id
132         return {'value': {'department_id': department_id, 'company_id': company_id}}
133
134     def expense_confirm(self, cr, uid, ids, context=None):
135         for expense in self.browse(cr, uid, ids):
136             if expense.employee_id and expense.employee_id.parent_id.user_id:
137                 self.message_subscribe_users(cr, uid, [expense.id], user_ids=[expense.employee_id.parent_id.user_id.id])
138         return self.write(cr, uid, ids, {'state': 'confirm', 'date_confirm': time.strftime('%Y-%m-%d')}, context=context)
139
140     def expense_accept(self, cr, uid, ids, context=None):
141         return self.write(cr, uid, ids, {'state': 'accepted', 'date_valid': time.strftime('%Y-%m-%d'), 'user_valid': uid}, context=context)
142
143     def expense_canceled(self, cr, uid, ids, context=None):
144         return self.write(cr, uid, ids, {'state': 'cancelled'}, context=context)
145
146     def account_move_get(self, cr, uid, expense_id, context=None):
147         '''
148         This method prepare the creation of the account move related to the given expense.
149
150         :param expense_id: Id of expense for which we are creating account_move.
151         :return: mapping between fieldname and value of account move to create
152         :rtype: dict
153         '''
154         journal_obj = self.pool.get('account.journal')
155         expense = self.browse(cr, uid, expense_id, context=context)
156         company_id = expense.company_id.id
157         date = expense.date_confirm
158         ref = expense.name
159         journal_id = False
160         if expense.journal_id:
161             journal_id = expense.journal_id.id
162         else:
163             journal_id = journal_obj.search(cr, uid, [('type', '=', 'purchase'), ('company_id', '=', company_id)])
164             if not journal_id:
165                 raise osv.except_osv(_('Error!'), _("No expense journal found. Please make sure you have a journal with type 'purchase' configured."))
166             journal_id = journal_id[0]
167         return self.pool.get('account.move').account_move_prepare(cr, uid, journal_id, date=date, ref=ref, company_id=company_id, context=context)
168
169     def line_get_convert(self, cr, uid, x, part, date, context=None):
170         partner_id  = self.pool.get('res.partner')._find_accounting_partner(part).id
171         return {
172             'date_maturity': x.get('date_maturity', False),
173             'partner_id': partner_id,
174             'name': x['name'][:64],
175             'date': date,
176             'debit': x['price']>0 and x['price'],
177             'credit': x['price']<0 and -x['price'],
178             'account_id': x['account_id'],
179             'analytic_lines': x.get('analytic_lines', False),
180             'amount_currency': x['price']>0 and abs(x.get('amount_currency', False)) or -abs(x.get('amount_currency', False)),
181             'currency_id': x.get('currency_id', False),
182             'tax_code_id': x.get('tax_code_id', False),
183             'tax_amount': x.get('tax_amount', False),
184             'ref': x.get('ref', False),
185             'quantity': x.get('quantity',1.00),
186             'product_id': x.get('product_id', False),
187             'product_uom_id': x.get('uos_id', False),
188             'analytic_account_id': x.get('account_analytic_id', False),
189         }
190
191     def compute_expense_totals(self, cr, uid, exp, company_currency, ref, account_move_lines, context=None):
192         '''
193         internal method used for computation of total amount of an expense in the company currency and
194         in the expense currency, given the account_move_lines that will be created. It also do some small
195         transformations at these account_move_lines (for multi-currency purposes)
196         
197         :param account_move_lines: list of dict
198         :rtype: tuple of 3 elements (a, b ,c)
199             a: total in company currency
200             b: total in hr.expense currency
201             c: account_move_lines potentially modified
202         '''
203         cur_obj = self.pool.get('res.currency')
204         if context is None:
205             context={}
206         context.update({'date': exp.date_confirm or time.strftime('%Y-%m-%d')})
207         total = 0.0
208         total_currency = 0.0
209         for i in account_move_lines:
210             if exp.currency_id.id != company_currency:
211                 i['currency_id'] = exp.currency_id.id
212                 i['amount_currency'] = i['price']
213                 i['price'] = cur_obj.compute(cr, uid, exp.currency_id.id,
214                         company_currency, i['price'],
215                         context=context)
216             else:
217                 i['amount_currency'] = False
218                 i['currency_id'] = False
219             total -= i['price']
220             total_currency -= i['amount_currency'] or i['price']
221         return total, total_currency, account_move_lines
222         
223     def action_move_create(self, cr, uid, ids, context=None):
224         '''
225         main function that is called when trying to create the accounting entries related to an expense
226         '''
227         move_obj = self.pool.get('account.move')
228         for exp in self.browse(cr, uid, ids, context=context):
229             if not exp.employee_id.address_home_id:
230                 raise osv.except_osv(_('Error!'), _('The employee must have a home address.'))
231             if not exp.employee_id.address_home_id.property_account_payable.id:
232                 raise osv.except_osv(_('Error!'), _('The employee must have a payable account set on his home address.'))
233             company_currency = exp.company_id.currency_id.id
234             diff_currency_p = exp.currency_id.id <> company_currency
235             
236             #create the move that will contain the accounting entries
237             move_id = move_obj.create(cr, uid, self.account_move_get(cr, uid, exp.id, context=context), context=context)
238         
239             #one account.move.line per expense line (+taxes..)
240             eml = self.move_line_get(cr, uid, exp.id, context=context)
241             
242             #create one more move line, a counterline for the total on payable account
243             total, total_currency, eml = self.compute_expense_totals(cr, uid, exp, company_currency, exp.name, eml, context=context)
244             acc = exp.employee_id.address_home_id.property_account_payable.id
245             eml.append({
246                     'type': 'dest',
247                     'name': '/',
248                     'price': total, 
249                     'account_id': acc, 
250                     'date_maturity': exp.date_confirm, 
251                     'amount_currency': diff_currency_p and total_currency or False, 
252                     'currency_id': diff_currency_p and exp.currency_id.id or False, 
253                     'ref': exp.name
254                     })
255
256             #convert eml into an osv-valid format
257             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)
258             move_obj.write(cr, uid, [move_id], {'line_id': lines}, context=context)
259             self.write(cr, uid, ids, {'account_move_id': move_id, 'state': 'done'}, context=context)
260         return True
261
262     def move_line_get(self, cr, uid, expense_id, context=None):
263         res = []
264         tax_obj = self.pool.get('account.tax')
265         cur_obj = self.pool.get('res.currency')
266         if context is None:
267             context = {}
268         exp = self.browse(cr, uid, expense_id, context=context)
269         company_currency = exp.company_id.currency_id.id
270
271         for line in exp.line_ids:
272             mres = self.move_line_get_item(cr, uid, line, context)
273             if not mres:
274                 continue
275             res.append(mres)
276             tax_code_found= False
277             
278             #Calculate tax according to default tax on product
279             
280             taxes = []
281             #Taken from product_id_onchange in account.invoice
282             if line.product_id:
283                 fposition_id = False
284                 fpos_obj = self.pool.get('account.fiscal.position')
285                 fpos = fposition_id and fpos_obj.browse(cr, uid, fposition_id, context=context) or False
286                 product = line.product_id
287                 taxes = product.supplier_taxes_id
288                 #If taxes are not related to the product, maybe they are in the account
289                 if not taxes:
290                     a = product.property_account_expense.id #Why is not there a check here?
291                     if not a:
292                         a = product.categ_id.property_account_expense_categ.id
293                     a = fpos_obj.map_account(cr, uid, fpos, a)
294                     taxes = a and self.pool.get('account.account').browse(cr, uid, a, context=context).tax_ids or False
295                 tax_id = fpos_obj.map_tax(cr, uid, fpos, taxes)
296             if not taxes:
297                 continue
298             #Calculating tax on the line and creating move?
299             for tax in tax_obj.compute_all(cr, uid, taxes,
300                     line.unit_amount ,
301                     line.unit_quantity, line.product_id,
302                     exp.user_id.partner_id)['taxes']:
303                 tax_code_id = tax['base_code_id']
304                 tax_amount = line.total_amount * tax['base_sign']
305                 if tax_code_found:
306                     if not tax_code_id:
307                         continue
308                     res.append(self.move_line_get_item(cr, uid, line, context))
309                     res[-1]['price'] = 0.0
310                     res[-1]['account_analytic_id'] = False
311                 elif not tax_code_id:
312                     continue
313                 tax_code_found = True
314                 res[-1]['tax_code_id'] = tax_code_id
315                 res[-1]['tax_amount'] = cur_obj.compute(cr, uid, exp.currency_id.id, company_currency, tax_amount, context={'date': exp.date_confirm})
316                 
317                 #Will create the tax here as we don't have the access 
318                 assoc_tax = {
319                              'type':'tax',
320                              'name':tax['name'],
321                              'price_unit': tax['price_unit'],
322                              'quantity': 1,
323                              'price':  tax['amount'] * tax['base_sign'] or 0.0,
324                              'account_id': tax['account_collected_id'] or mres['account_id'],
325                              'tax_code_id': tax['tax_code_id'],
326                              'tax_amount': tax['amount'] * tax['base_sign'],
327                              }
328                 res.append(assoc_tax)
329         return res
330
331     def move_line_get_item(self, cr, uid, line, context=None):
332         company = line.expense_id.company_id
333         property_obj = self.pool.get('ir.property')
334         if line.product_id:
335             acc = line.product_id.property_account_expense
336             if not acc:
337                 acc = line.product_id.categ_id.property_account_expense_categ
338             if not acc:
339                 raise osv.except_osv(_('Error!'), _('No purchase account found for the product %s (or for his category), please configure one.') % (line.product_id.name))
340         else:
341             acc = property_obj.get(cr, uid, 'property_account_expense_categ', 'product.category', context={'force_company': company.id})
342             if not acc:
343                 raise osv.except_osv(_('Error!'), _('Please configure Default Expense account for Product purchase: `property_account_expense_categ`.'))
344         return {
345             'type':'src',
346             'name': line.name.split('\n')[0][:64],
347             'price_unit':line.unit_amount,
348             'quantity':line.unit_quantity,
349             'price':line.total_amount,
350             'account_id':acc.id,
351             'product_id':line.product_id.id,
352             'uos_id':line.uom_id.id,
353             'account_analytic_id':line.analytic_account.id,
354         }
355
356     def action_view_move(self, cr, uid, ids, context=None):
357         '''
358         This function returns an action that display existing account.move of given expense ids.
359         '''
360         assert len(ids) == 1, 'This option should only be used for a single id at a time'
361         expense = self.browse(cr, uid, ids[0], context=context)
362         assert expense.account_move_id
363         try:
364             dummy, view_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'account', 'view_move_form')
365         except ValueError, e:
366             view_id = False
367         result = {
368             'name': _('Expense Account Move'),
369             'view_type': 'form',
370             'view_mode': 'form',
371             'view_id': view_id,
372             'res_model': 'account.move',
373             'type': 'ir.actions.act_window',
374             'nodestroy': True,
375             'target': 'current',
376             'res_id': expense.account_move_id.id,
377         }
378         return result
379
380 hr_expense_expense()
381
382 class product_product(osv.osv):
383     _inherit = "product.product"
384     _columns = {
385         'hr_expense_ok': fields.boolean('Can be Expensed', help="Specify if the product can be selected in an HR expense line."),
386     }
387
388 product_product()
389
390 class hr_expense_line(osv.osv):
391     _name = "hr.expense.line"
392     _description = "Expense Line"
393
394     def _amount(self, cr, uid, ids, field_name, arg, context=None):
395         if not ids:
396             return {}
397         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),))
398         res = dict(cr.fetchall())
399         return res
400
401     def _get_uom_id(self, cr, uid, context=None):
402         result = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'product', 'product_uom_unit')
403         return result and result[1] or False
404
405     _columns = {
406         'name': fields.char('Expense Note', size=128, required=True),
407         'date_value': fields.date('Date', required=True),
408         'expense_id': fields.many2one('hr.expense.expense', 'Expense', ondelete='cascade', select=True),
409         'total_amount': fields.function(_amount, string='Total', digits_compute=dp.get_precision('Account')),
410         'unit_amount': fields.float('Unit Price', digits_compute=dp.get_precision('Product Price')),
411         'unit_quantity': fields.float('Quantities', digits_compute= dp.get_precision('Product Unit of Measure')),
412         'product_id': fields.many2one('product.product', 'Product', domain=[('hr_expense_ok','=',True)]),
413         'uom_id': fields.many2one('product.uom', 'Unit of Measure', required=True),
414         'description': fields.text('Description'),
415         'analytic_account': fields.many2one('account.analytic.account','Analytic account'),
416         'ref': fields.char('Reference', size=32),
417         'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of expense lines."),
418         }
419     _defaults = {
420         'unit_quantity': 1,
421         'date_value': lambda *a: time.strftime('%Y-%m-%d'),
422         'uom_id': _get_uom_id,
423     }
424     _order = "sequence, date_value desc"
425
426     def onchange_product_id(self, cr, uid, ids, product_id, context=None):
427         res = {}
428         if product_id:
429             product = self.pool.get('product.product').browse(cr, uid, product_id, context=context)
430             res['name'] = product.name
431             amount_unit = product.price_get('standard_price')[product.id]
432             res['unit_amount'] = amount_unit
433             res['uom_id'] = product.uom_id.id
434         return {'value': res}
435
436     def onchange_uom(self, cr, uid, ids, product_id, uom_id, context=None):
437         res = {'value':{}}
438         if not uom_id or not product_id:
439             return res
440         product = self.pool.get('product.product').browse(cr, uid, product_id, context=context)
441         uom = self.pool.get('product.uom').browse(cr, uid, uom_id, context=context)
442         if uom.category_id.id != product.uom_id.category_id.id:
443             res['warning'] = {'title': _('Warning'), 'message': _('Selected Unit of Measure does not belong to the same category as the product Unit of Measure')}
444             res['value'].update({'uom_id': product.uom_id.id})
445         return res
446
447 hr_expense_line()
448
449 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: