[FIX] website: pass `lang` to js and css bundle controllers
[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             tax_code_found= False
289             
290             #Calculate tax according to default tax on product
291             taxes = []
292             #Taken from product_id_onchange in account.invoice
293             if line.product_id:
294                 fposition_id = False
295                 fpos_obj = self.pool.get('account.fiscal.position')
296                 fpos = fposition_id and fpos_obj.browse(cr, uid, fposition_id, context=context) or False
297                 product = line.product_id
298                 taxes = product.supplier_taxes_id
299                 #If taxes are not related to the product, maybe they are in the account
300                 if not taxes:
301                     a = product.property_account_expense.id #Why is not there a check here?
302                     if not a:
303                         a = product.categ_id.property_account_expense_categ.id
304                     a = fpos_obj.map_account(cr, uid, fpos, a)
305                     taxes = a and self.pool.get('account.account').browse(cr, uid, a, context=context).tax_ids or False
306                 tax_id = fpos_obj.map_tax(cr, uid, fpos, taxes)
307             if not taxes:
308                 continue
309             #Calculating tax on the line and creating move?
310             for tax in tax_obj.compute_all(cr, uid, taxes,
311                     line.unit_amount ,
312                     line.unit_quantity, line.product_id,
313                     exp.user_id.partner_id)['taxes']:
314                 tax_code_id = tax['base_code_id']
315                 tax_amount = line.total_amount * tax['base_sign']
316                 if tax_code_found:
317                     if not tax_code_id:
318                         continue
319                     res.append(self.move_line_get_item(cr, uid, line, context))
320                     res[-1]['price'] = 0.0
321                     res[-1]['account_analytic_id'] = False
322                 elif not tax_code_id:
323                     continue
324                 tax_code_found = True
325                 res[-1]['tax_code_id'] = tax_code_id
326                 res[-1]['tax_amount'] = cur_obj.compute(cr, uid, exp.currency_id.id, company_currency, tax_amount, context={'date': exp.date_confirm})
327                 ## 
328                 is_price_include = tax_obj.read(cr,uid,tax['id'],['price_include'],context)['price_include']
329                 if is_price_include:
330                     ## We need to deduce the price for the tax
331                     res[-1]['price'] = res[-1]['price']  - (tax['amount'] * tax['base_sign'] or 0.0)
332                 assoc_tax = {
333                              'type':'tax',
334                              'name':tax['name'],
335                              'price_unit': tax['price_unit'],
336                              'quantity': 1,
337                              'price':  tax['amount'] * tax['base_sign'] or 0.0,
338                              'account_id': tax['account_collected_id'] or mres['account_id'],
339                              'tax_code_id': tax['tax_code_id'],
340                              'tax_amount': tax['amount'] * tax['base_sign'],
341                              }
342                 res.append(assoc_tax)
343         return res
344
345     def move_line_get_item(self, cr, uid, line, context=None):
346         company = line.expense_id.company_id
347         property_obj = self.pool.get('ir.property')
348         if line.product_id:
349             acc = line.product_id.property_account_expense
350             if not acc:
351                 acc = line.product_id.categ_id.property_account_expense_categ
352             if not acc:
353                 raise osv.except_osv(_('Error!'), _('No purchase account found for the product %s (or for his category), please configure one.') % (line.product_id.name))
354         else:
355             acc = property_obj.get(cr, uid, 'property_account_expense_categ', 'product.category', context={'force_company': company.id})
356             if not acc:
357                 raise osv.except_osv(_('Error!'), _('Please configure Default Expense account for Product purchase: `property_account_expense_categ`.'))
358         return {
359             'type':'src',
360             'name': line.name.split('\n')[0][:64],
361             'price_unit':line.unit_amount,
362             'quantity':line.unit_quantity,
363             'price':line.total_amount,
364             'account_id':acc.id,
365             'product_id':line.product_id.id,
366             'uos_id':line.uom_id.id,
367             'account_analytic_id':line.analytic_account.id,
368         }
369
370     def action_view_move(self, cr, uid, ids, context=None):
371         '''
372         This function returns an action that display existing account.move of given expense ids.
373         '''
374         assert len(ids) == 1, 'This option should only be used for a single id at a time'
375         expense = self.browse(cr, uid, ids[0], context=context)
376         assert expense.account_move_id
377         try:
378             dummy, view_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'account', 'view_move_form')
379         except ValueError, e:
380             view_id = False
381         result = {
382             'name': _('Expense Account Move'),
383             'view_type': 'form',
384             'view_mode': 'form',
385             'view_id': view_id,
386             'res_model': 'account.move',
387             'type': 'ir.actions.act_window',
388             'nodestroy': True,
389             'target': 'current',
390             'res_id': expense.account_move_id.id,
391         }
392         return result
393
394
395 class product_product(osv.osv):
396     _inherit = "product.product"
397     _columns = {
398         'hr_expense_ok': fields.boolean('Can be Expensed', help="Specify if the product can be selected in an HR expense line."),
399     }
400
401
402 class hr_expense_line(osv.osv):
403     _name = "hr.expense.line"
404     _description = "Expense Line"
405
406     def _amount(self, cr, uid, ids, field_name, arg, context=None):
407         if not ids:
408             return {}
409         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),))
410         res = dict(cr.fetchall())
411         return res
412
413     def _get_uom_id(self, cr, uid, context=None):
414         result = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'product', 'product_uom_unit')
415         return result and result[1] or False
416
417     _columns = {
418         'name': fields.char('Expense Note', size=128, required=True),
419         'date_value': fields.date('Date', required=True),
420         'expense_id': fields.many2one('hr.expense.expense', 'Expense', ondelete='cascade', select=True),
421         'total_amount': fields.function(_amount, string='Total', digits_compute=dp.get_precision('Account')),
422         'unit_amount': fields.float('Unit Price', digits_compute=dp.get_precision('Product Price')),
423         'unit_quantity': fields.float('Quantities', digits_compute= dp.get_precision('Product Unit of Measure')),
424         'product_id': fields.many2one('product.product', 'Product', domain=[('hr_expense_ok','=',True)]),
425         'uom_id': fields.many2one('product.uom', 'Unit of Measure', required=True),
426         'description': fields.text('Description'),
427         'analytic_account': fields.many2one('account.analytic.account','Analytic account'),
428         'ref': fields.char('Reference', size=32),
429         'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of expense lines."),
430         }
431     _defaults = {
432         'unit_quantity': 1,
433         'date_value': lambda *a: time.strftime('%Y-%m-%d'),
434         'uom_id': _get_uom_id,
435     }
436     _order = "sequence, date_value desc"
437
438     def onchange_product_id(self, cr, uid, ids, product_id, context=None):
439         res = {}
440         if product_id:
441             product = self.pool.get('product.product').browse(cr, uid, product_id, context=context)
442             res['name'] = product.name
443             amount_unit = product.price_get('standard_price')[product.id]
444             res['unit_amount'] = amount_unit
445             res['uom_id'] = product.uom_id.id
446         return {'value': res}
447
448     def onchange_uom(self, cr, uid, ids, product_id, uom_id, context=None):
449         res = {'value':{}}
450         if not uom_id or not product_id:
451             return res
452         product = self.pool.get('product.product').browse(cr, uid, product_id, context=context)
453         uom = self.pool.get('product.uom').browse(cr, uid, uom_id, context=context)
454         if uom.category_id.id != product.uom_id.category_id.id:
455             res['warning'] = {'title': _('Warning'), 'message': _('Selected Unit of Measure does not belong to the same category as the product Unit of Measure')}
456             res['value'].update({'uom_id': product.uom_id.id})
457         return res
458
459
460 class account_move_line(osv.osv):
461     _inherit = "account.move.line"
462
463     def reconcile(self, cr, uid, ids, type='auto', writeoff_acc_id=False, writeoff_period_id=False, writeoff_journal_id=False, context=None):
464         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)
465         #when making a full reconciliation of account move lines 'ids', we may need to recompute the state of some hr.expense
466         account_move_ids = [aml.move_id.id for aml in self.browse(cr, uid, ids, context=context)]
467         expense_obj = self.pool.get('hr.expense.expense')
468         currency_obj = self.pool.get('res.currency')
469         if account_move_ids:
470             expense_ids = expense_obj.search(cr, uid, [('account_move_id', 'in', account_move_ids)], context=context)
471             for expense in expense_obj.browse(cr, uid, expense_ids, context=context):
472                 if expense.state == 'done':
473                     #making the postulate it has to be set paid, then trying to invalidate it
474                     new_status_is_paid = True
475                     for aml in expense.account_move_id.line_id:
476                         if aml.account_id.type == 'payable' and not currency_obj.is_zero(cr, uid, expense.company_id.currency_id, aml.amount_residual):
477                             new_status_is_paid = False
478                     if new_status_is_paid:
479                         expense_obj.write(cr, uid, [expense.id], {'state': 'paid'}, context=context)
480         return res
481
482 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: