[MERGE] forward port of branch saas-5 up to 7eab880
[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', 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, copy=False,
75                                     help="Date of the confirmation of the sheet expense. It's filled when the button Confirm is pressed."),
76         'date_valid': fields.date('Validation Date', select=True, copy=False,
77                                   help="Date of the acceptation of the sheet expense. It's filled when the button Accept is pressed."),
78         'user_valid': fields.many2one('res.users', 'Validation By', readonly=True, copy=False,
79                                       states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
80         'account_move_id': fields.many2one('account.move', 'Ledger Posting', copy=False),
81         'line_ids': fields.one2many('hr.expense.line', 'expense_id', 'Expense Lines', copy=True,
82                                     readonly=True, states={'draft':[('readonly',False)]} ),
83         'note': fields.text('Note'),
84         'amount': fields.function(_amount, string='Total Amount', digits_compute=dp.get_precision('Account'), 
85             store={
86                 'hr.expense.line': (_get_expense_from_line, ['unit_amount','unit_quantity'], 10)
87             }),
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', 'Waiting Payment'),
97             ('paid', 'Paid'),
98             ],
99             'Status', readonly=True, track_visibility='onchange', copy=False,
100             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\'.\
101             \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\'.'),
102
103     }
104     _defaults = {
105         'company_id': lambda s, cr, uid, c: s.pool.get('res.company')._company_default_get(cr, uid, 'hr.employee', context=c),
106         'date': fields.date.context_today,
107         'state': 'draft',
108         'employee_id': _employee_get,
109         'user_id': lambda cr, uid, id, c={}: id,
110         'currency_id': _get_currency,
111     }
112
113     def unlink(self, cr, uid, ids, context=None):
114         for rec in self.browse(cr, uid, ids, context=context):
115             if rec.state != 'draft':
116                 raise osv.except_osv(_('Warning!'),_('You can only delete draft expenses!'))
117         return super(hr_expense_expense, self).unlink(cr, uid, ids, context)
118
119     def onchange_currency_id(self, cr, uid, ids, currency_id=False, company_id=False, context=None):
120         res =  {'value': {'journal_id': False}}
121         journal_ids = self.pool.get('account.journal').search(cr, uid, [('type','=','purchase'), ('currency','=',currency_id), ('company_id', '=', company_id)], context=context)
122         if journal_ids:
123             res['value']['journal_id'] = journal_ids[0]
124         return res
125
126     def onchange_employee_id(self, cr, uid, ids, employee_id, context=None):
127         emp_obj = self.pool.get('hr.employee')
128         department_id = False
129         company_id = False
130         if employee_id:
131             employee = emp_obj.browse(cr, uid, employee_id, context=context)
132             department_id = employee.department_id.id
133             company_id = employee.company_id.id
134         return {'value': {'department_id': department_id, 'company_id': company_id}}
135
136     def expense_confirm(self, cr, uid, ids, context=None):
137         for expense in self.browse(cr, uid, ids):
138             if expense.employee_id and expense.employee_id.parent_id.user_id:
139                 self.message_subscribe_users(cr, uid, [expense.id], user_ids=[expense.employee_id.parent_id.user_id.id])
140         return self.write(cr, uid, ids, {'state': 'confirm', 'date_confirm': time.strftime('%Y-%m-%d')}, context=context)
141
142     def expense_accept(self, cr, uid, ids, context=None):
143         return self.write(cr, uid, ids, {'state': 'accepted', 'date_valid': time.strftime('%Y-%m-%d'), 'user_valid': uid}, context=context)
144
145     def expense_canceled(self, cr, uid, ids, context=None):
146         return self.write(cr, uid, ids, {'state': 'cancelled'}, context=context)
147
148     def account_move_get(self, cr, uid, expense_id, context=None):
149         '''
150         This method prepare the creation of the account move related to the given expense.
151
152         :param expense_id: Id of expense for which we are creating account_move.
153         :return: mapping between fieldname and value of account move to create
154         :rtype: dict
155         '''
156         journal_obj = self.pool.get('account.journal')
157         expense = self.browse(cr, uid, expense_id, context=context)
158         company_id = expense.company_id.id
159         date = expense.date_confirm
160         ref = expense.name
161         journal_id = False
162         if expense.journal_id:
163             journal_id = expense.journal_id.id
164         else:
165             journal_id = journal_obj.search(cr, uid, [('type', '=', 'purchase'), ('company_id', '=', company_id)])
166             if not journal_id:
167                 raise osv.except_osv(_('Error!'), _("No expense journal found. Please make sure you have a journal with type 'purchase' configured."))
168             journal_id = journal_id[0]
169         return self.pool.get('account.move').account_move_prepare(cr, uid, journal_id, date=date, ref=ref, company_id=company_id, context=context)
170
171     def line_get_convert(self, cr, uid, x, part, date, context=None):
172         partner_id  = self.pool.get('res.partner')._find_accounting_partner(part).id
173         return {
174             'date_maturity': x.get('date_maturity', False),
175             'partner_id': partner_id,
176             'name': x['name'][:64],
177             'date': date,
178             'debit': x['price']>0 and x['price'],
179             'credit': x['price']<0 and -x['price'],
180             'account_id': x['account_id'],
181             'analytic_lines': x.get('analytic_lines', False),
182             'amount_currency': x['price']>0 and abs(x.get('amount_currency', False)) or -abs(x.get('amount_currency', False)),
183             'currency_id': x.get('currency_id', False),
184             'tax_code_id': x.get('tax_code_id', False),
185             'tax_amount': x.get('tax_amount', False),
186             'ref': x.get('ref', False),
187             'quantity': x.get('quantity',1.00),
188             'product_id': x.get('product_id', False),
189             'product_uom_id': x.get('uos_id', False),
190             'analytic_account_id': x.get('account_analytic_id', False),
191         }
192
193     def compute_expense_totals(self, cr, uid, exp, company_currency, ref, account_move_lines, context=None):
194         '''
195         internal method used for computation of total amount of an expense in the company currency and
196         in the expense currency, given the account_move_lines that will be created. It also do some small
197         transformations at these account_move_lines (for multi-currency purposes)
198         
199         :param account_move_lines: list of dict
200         :rtype: tuple of 3 elements (a, b ,c)
201             a: total in company currency
202             b: total in hr.expense currency
203             c: account_move_lines potentially modified
204         '''
205         cur_obj = self.pool.get('res.currency')
206         context = dict(context or {}, 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             journal_id = move_obj.browse(cr, uid, move_id, context).journal_id
259             # post the journal entry if 'Skip 'Draft' State for Manual Entries' is checked
260             if journal_id.entry_posted:
261                 move_obj.button_validate(cr, uid, [move_id], context)
262             move_obj.write(cr, uid, [move_id], {'line_id': lines}, context=context)
263             self.write(cr, uid, ids, {'account_move_id': move_id, 'state': 'done'}, context=context)
264         return True
265
266     def move_line_get(self, cr, uid, expense_id, context=None):
267         res = []
268         tax_obj = self.pool.get('account.tax')
269         cur_obj = self.pool.get('res.currency')
270         if context is None:
271             context = {}
272         exp = self.browse(cr, uid, expense_id, context=context)
273         company_currency = exp.company_id.currency_id.id
274
275         for line in exp.line_ids:
276             mres = self.move_line_get_item(cr, uid, line, context)
277             if not mres:
278                 continue
279             res.append(mres)
280             
281             #Calculate tax according to default tax on product
282             taxes = []
283             #Taken from product_id_onchange in account.invoice
284             if line.product_id:
285                 fposition_id = False
286                 fpos_obj = self.pool.get('account.fiscal.position')
287                 fpos = fposition_id and fpos_obj.browse(cr, uid, fposition_id, context=context) or False
288                 product = line.product_id
289                 taxes = product.supplier_taxes_id
290                 #If taxes are not related to the product, maybe they are in the account
291                 if not taxes:
292                     a = product.property_account_expense.id #Why is not there a check here?
293                     if not a:
294                         a = product.categ_id.property_account_expense_categ.id
295                     a = fpos_obj.map_account(cr, uid, fpos, a)
296                     taxes = a and self.pool.get('account.account').browse(cr, uid, a, context=context).tax_ids or False
297             if not taxes:
298                 continue
299             tax_l = []
300             #Calculating tax on the line and creating move?
301             for tax in tax_obj.compute_all(cr, uid, taxes,
302                     line.unit_amount ,
303                     line.unit_quantity, line.product_id,
304                     exp.user_id.partner_id)['taxes']:
305                 tax_code_id = tax['base_code_id']
306                 if not tax_code_id:
307                     continue
308                 res[-1]['tax_code_id'] = tax_code_id
309                 ## 
310                 is_price_include = tax_obj.read(cr,uid,tax['id'],['price_include'],context)['price_include']
311                 if is_price_include:
312                     ## We need to deduce the price for the tax
313                     res[-1]['price'] = res[-1]['price']  - (tax['amount'] * tax['base_sign'] or 0.0)
314                     # tax amount countains base amount without the tax
315                     tax_amount = (line.total_amount - tax['amount']) * tax['base_sign']
316                 else:
317                     tax_amount = line.total_amount * tax['base_sign']
318                 res[-1]['tax_amount'] = cur_obj.compute(cr, uid, exp.currency_id.id, company_currency, tax_amount, context={'date': exp.date_confirm})
319                 assoc_tax = {
320                              'type':'tax',
321                              'name':tax['name'],
322                              'price_unit': tax['price_unit'],
323                              'quantity': 1,
324                              'price':  tax['amount'] * tax['base_sign'] or 0.0,
325                              'account_id': tax['account_collected_id'] or mres['account_id'],
326                              'tax_code_id': tax['tax_code_id'],
327                              'tax_amount': tax['amount'] * tax['base_sign'],
328                              }
329                 tax_l.append(assoc_tax)
330             res += tax_l
331         return res
332
333     def move_line_get_item(self, cr, uid, line, context=None):
334         company = line.expense_id.company_id
335         property_obj = self.pool.get('ir.property')
336         if line.product_id:
337             acc = line.product_id.property_account_expense
338             if not acc:
339                 acc = line.product_id.categ_id.property_account_expense_categ
340             if not acc:
341                 raise osv.except_osv(_('Error!'), _('No purchase account found for the product %s (or for his category), please configure one.') % (line.product_id.name))
342         else:
343             acc = property_obj.get(cr, uid, 'property_account_expense_categ', 'product.category', context={'force_company': company.id})
344             if not acc:
345                 raise osv.except_osv(_('Error!'), _('Please configure Default Expense account for Product purchase: `property_account_expense_categ`.'))
346         return {
347             'type':'src',
348             'name': line.name.split('\n')[0][:64],
349             'price_unit':line.unit_amount,
350             'quantity':line.unit_quantity,
351             'price':line.total_amount,
352             'account_id':acc.id,
353             'product_id':line.product_id.id,
354             'uos_id':line.uom_id.id,
355             'account_analytic_id':line.analytic_account.id,
356         }
357
358     def action_view_move(self, cr, uid, ids, context=None):
359         '''
360         This function returns an action that display existing account.move of given expense ids.
361         '''
362         assert len(ids) == 1, 'This option should only be used for a single id at a time'
363         expense = self.browse(cr, uid, ids[0], context=context)
364         assert expense.account_move_id
365         try:
366             dummy, view_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'account', 'view_move_form')
367         except ValueError, e:
368             view_id = False
369         result = {
370             'name': _('Expense Account Move'),
371             'view_type': 'form',
372             'view_mode': 'form',
373             'view_id': view_id,
374             'res_model': 'account.move',
375             'type': 'ir.actions.act_window',
376             'nodestroy': True,
377             'target': 'current',
378             'res_id': expense.account_move_id.id,
379         }
380         return result
381
382
383 class product_template(osv.osv):
384     _inherit = "product.template"
385     _columns = {
386         'hr_expense_ok': fields.boolean('Can be Expensed', help="Specify if the product can be selected in an HR expense line."),
387     }
388
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', 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'),
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
448 class account_move_line(osv.osv):
449     _inherit = "account.move.line"
450
451     def reconcile(self, cr, uid, ids, type='auto', writeoff_acc_id=False, writeoff_period_id=False, writeoff_journal_id=False, context=None):
452         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)
453         #when making a full reconciliation of account move lines 'ids', we may need to recompute the state of some hr.expense
454         account_move_ids = [aml.move_id.id for aml in self.browse(cr, uid, ids, context=context)]
455         expense_obj = self.pool.get('hr.expense.expense')
456         currency_obj = self.pool.get('res.currency')
457         if account_move_ids:
458             expense_ids = expense_obj.search(cr, uid, [('account_move_id', 'in', account_move_ids)], context=context)
459             for expense in expense_obj.browse(cr, uid, expense_ids, context=context):
460                 if expense.state == 'done':
461                     #making the postulate it has to be set paid, then trying to invalidate it
462                     new_status_is_paid = True
463                     for aml in expense.account_move_id.line_id:
464                         if aml.account_id.type == 'payable' and not currency_obj.is_zero(cr, uid, expense.company_id.currency_id, aml.amount_residual):
465                             new_status_is_paid = False
466                     if new_status_is_paid:
467                         expense_obj.write(cr, uid, [expense.id], {'state': 'paid'}, context=context)
468         return res
469
470 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: