[REF] hr_expense, creation of accounting entries from hr.expense: a lot of code refac...
[odoo/odoo.git] / addons / hr_expense / hr_expense.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2004-2010 Tiny SPRL (<http://tiny.be>).
6 #
7 #    This program is free software: you can redistribute it and/or modify
8 #    it under the terms of the GNU Affero General Public License as
9 #    published by the Free Software Foundation, either version 3 of the
10 #    License, or (at your option) any later version.
11 #
12 #    This program is distributed in the hope that it will be useful,
13 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
14 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 #    GNU Affero General Public License for more details.
16 #
17 #    You should have received a copy of the GNU Affero General Public License
18 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
19 #
20 ##############################################################################
21
22 import time
23
24 from openerp import netsvc
25 from openerp.osv import fields, osv
26 from openerp.tools.translate import _
27
28 import openerp.addons.decimal_precision as dp
29
30 def _employee_get(obj, cr, uid, context=None):
31     if context is None:
32         context = {}
33     ids = obj.pool.get('hr.employee').search(cr, uid, [('user_id', '=', uid)], context=context)
34     if ids:
35         return ids[0]
36     return False
37
38 class hr_expense_expense(osv.osv):
39
40     def copy(self, cr, uid, id, default=None, context=None):
41         if context is None:
42             context = {}
43         if not default: default = {}
44         default.update({'voucher_id': False, 'date_confirm': False, 'date_valid': False, 'user_valid': False})
45         return super(hr_expense_expense, self).copy(cr, uid, id, default, context=context)
46
47     def _amount(self, cr, uid, ids, field_name, arg, context=None):
48         res= {}
49         for expense in self.browse(cr, uid, ids, context=context):
50             total = 0.0
51             for line in expense.line_ids:
52                 total += line.unit_amount * line.unit_quantity
53             res[expense.id] = total
54         return res
55
56     def _get_currency(self, cr, uid, context=None):
57         user = self.pool.get('res.users').browse(cr, uid, [uid], context=context)[0]
58         if user.company_id:
59             return user.company_id.currency_id.id
60         else:
61             return self.pool.get('res.currency').search(cr, uid, [('rate','=',1.0)], context=context)[0]
62
63     _name = "hr.expense.expense"
64     _inherit = ['mail.thread']
65     _description = "Expense"
66     _order = "id desc"
67     _track = {
68         'state': {
69             'hr_expense.mt_expense_approved': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'accepted',
70             'hr_expense.mt_expense_refused': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'cancelled',
71             'hr_expense.mt_expense_confirmed': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'confirm',
72         },
73     }
74
75     _columns = {
76         'name': fields.char('Description', size=128, required=True, readonly=True, states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
77         'id': fields.integer('Sheet ID', readonly=True),
78         'date': fields.date('Date', select=True, readonly=True, states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
79         'journal_id': fields.many2one('account.journal', 'Force Journal', help = "The journal used when the expense is done."),
80         'employee_id': fields.many2one('hr.employee', "Employee", required=True, readonly=True, states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
81         'user_id': fields.many2one('res.users', 'User', required=True),
82         '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."),
83         '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."),
84         'user_valid': fields.many2one('res.users', 'Validation By', readonly=True, states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
85         'account_move_id': fields.many2one('account.move', 'Ledger Posting'),
86         'line_ids': fields.one2many('hr.expense.line', 'expense_id', 'Expense Lines', readonly=True, states={'draft':[('readonly',False)]} ),
87         'note': fields.text('Note'),
88         'amount': fields.function(_amount, string='Total Amount', digits_compute=dp.get_precision('Account')),
89         'voucher_id': fields.many2one('account.voucher', "Employee's Receipt"),
90         'currency_id': fields.many2one('res.currency', 'Currency', required=True, readonly=True, states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
91         'department_id':fields.many2one('hr.department','Department', readonly=True, states={'draft':[('readonly',False)], 'confirm':[('readonly',False)]}),
92         'company_id': fields.many2one('res.company', 'Company', required=True),
93         'state': fields.selection([
94             ('draft', 'New'),
95             ('cancelled', 'Refused'),
96             ('confirm', 'Waiting Approval'),
97             ('accepted', 'Approved'),
98             ('done', 'Done'),
99             ],
100             'Status', readonly=True, track_visibility='onchange',
101             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\'.\
102             \nIf the admin accepts it, the status is \'Accepted\'.\n If a receipt is made for the expense request, the status is \'Done\'.'),
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 voucher 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_partner(part)
173         partner_id = part.id
174         return {
175             'date_maturity': x.get('date_maturity', False),
176             'partner_id': partner_id,
177             'name': x['name'][:64],
178             'date': date,
179             'debit': x['price']>0 and x['price'],
180             'credit': x['price']<0 and -x['price'],
181             'account_id': x['account_id'],
182             'analytic_lines': x.get('analytic_lines', False),
183             'amount_currency': x['price']>0 and abs(x.get('amount_currency', False)) or -abs(x.get('amount_currency', False)),
184             'currency_id': x.get('currency_id', False),
185             'tax_code_id': x.get('tax_code_id', False),
186             'tax_amount': x.get('tax_amount', False),
187             'ref': x.get('ref', False),
188             'quantity': x.get('quantity',1.00),
189             'product_id': x.get('product_id', False),
190             'product_uom_id': x.get('uos_id', False),
191             'analytic_account_id': x.get('account_analytic_id', False),
192         }
193
194     def compute_expense_totals(self, cr, uid, exp, company_currency, ref, account_move_lines, context=None):
195         '''
196         internal method used for computation of total amount of an expense in the company currency and
197         in the expense currency, given the account_move_lines that will be created. It also do some small
198         transformations at these account_move_lines (for multi-currency purposes)
199         
200         :param account_move_lines: list of dict
201         :rtype: tuple of 3 elements (a, b ,c)
202             a: total in company currency
203             b: total in hr.expense currency
204             c: account_move_lines potentially modified
205         '''
206         cur_obj = self.pool.get('res.currency')
207         if context is None:
208             context={}
209         context.update({'date': exp.date_confirm or time.strftime('%Y-%m-%d')})
210         total = 0.0
211         total_currency = 0.0
212         for i in account_move_lines:
213             if exp.currency_id.id != company_currency:
214                 i['currency_id'] = exp.currency_id.id
215                 i['amount_currency'] = i['price']
216                 i['price'] = cur_obj.compute(cr, uid, exp.currency_id.id,
217                         company_currency, i['price'],
218                         context=context)
219             else:
220                 i['amount_currency'] = False
221                 i['currency_id'] = False
222             total -= i['price']
223             total_currency -= i['amount_currency'] or i['price']
224         return total, total_currency, account_move_lines
225
226
227     def action_move_create(self, cr, uid, ids, context=None):
228         move_obj = self.pool.get('account.move')
229         if context is None:
230             context = {}
231         for exp in self.browse(cr, uid, ids, context=context):
232             if not exp.employee_id.address_home_id:
233                 raise osv.except_osv(_('Error!'), _('The employee must have a home address.'))
234             company_currency = exp.company_id.currency_id.id
235             diff_currency_p = exp.currency_id.id <> company_currency
236             
237             #create the move that will contain the accounting entries
238             move_id = move_obj.create(cr, uid, self.account_move_get(cr, uid, exp.id, context=context), context=context)
239             #iml = self._get_analytic_lines
240             
241             # within: iml = self.pool.get('account.invoice.line').move_line_get
242             iml = self.move_line_get(cr, uid, exp.id, context=context)
243             
244             # create one move line for the total
245             total, total_currency, iml = self.compute_expense_totals(cr, uid, exp, company_currency, exp.name, iml, context=context)
246             
247             #counterline with the total on payable account for the employee
248             acc = exp.employee_id.address_home_id.property_account_payable.id
249             iml.append({
250                     'type': 'dest',
251                     'name': '/',
252                     'price': total, 
253                     'account_id': acc, 
254                     'date_maturity': exp.date_confirm, 
255                     'amount_currency': diff_currency_p and total_currency or False, 
256                     'currency_id': diff_currency_p and exp.currency_id.id or False, 
257                     'ref': exp.name
258                     })
259             
260             lines = map(lambda x:(0,0,self.line_get_convert(cr, uid, x, exp.user_id.partner_id, exp.date_confirm, context=context)),iml)
261             move_obj.write(cr, uid, [move_id], {'line_id': lines}, context=context)
262             self.write(cr, uid, ids, {'account_move_id': move_id, 'state': 'done'}, context=context)
263         return True
264
265     def move_line_get(self, cr, uid, expense_id, context=None):
266         res = []
267         tax_obj = self.pool.get('account.tax')
268         cur_obj = self.pool.get('res.currency')
269         if context is None:
270             context = {}
271         exp = self.browse(cr, uid, expense_id, context=context)
272         company_currency = exp.company_id.currency_id.id
273
274         for line in exp.line_ids:
275             mres = self.move_line_get_item(cr, uid, line, context)
276             if not mres:
277                 continue
278             res.append(mres)
279             tax_code_found= False
280             
281             #Calculate tax according to default tax on product
282             
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                 tax_id = fpos_obj.map_tax(cr, uid, fpos, taxes)
298             if not taxes:
299                 taxes = []
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                 tax_amount = line.total_amount * tax['base_sign']
307                 if tax_code_found:
308                     if not tax_code_id:
309                         continue
310                     res.append(self.move_line_get_item(cr, uid, line, context))
311                     res[-1]['price'] = 0.0
312                     res[-1]['account_analytic_id'] = False
313                 elif not tax_code_id:
314                     continue
315                 tax_code_found = True
316                 res[-1]['tax_code_id'] = tax_code_id
317                 res[-1]['tax_amount'] = cur_obj.compute(cr, uid, exp.currency_id.id, company_currency, tax_amount, context={'date': exp.date_confirm})
318                 
319                 #Will create the tax here as we don't have the access 
320                 assoc_tax = {
321                              'type':'tax',
322                              'name':tax['name'],
323                              'price_unit': tax['price_unit'],
324                              'quantity': 1,
325                              'price':  tax['amount'] * tax['base_sign'] or 0.0,
326                              'account_id': tax['account_collected_id'],
327                              'tax_code_id': tax['tax_code_id'],
328                              'tax_amount': tax['amount'] * tax['base_sign'],
329                              }
330                 res.append(assoc_tax)
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         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             #'taxes':line.invoice_line_tax_id,
355         }
356
357
358     def action_receipt_create(self, cr, uid, ids, context=None):
359         raise osv.except_osv(_('Error!'), _('Deprecated function used'))
360     
361     def action_view_receipt(self, cr, uid, ids, context=None):
362         raise osv.except_osv(_('Error!'), _('Deprecated function used'))
363         '''
364         This function returns an action that display existing receipt of given expense ids.
365         '''
366         assert len(ids) == 1, 'This option should only be used for a single id at a time'
367         voucher_id = self.browse(cr, uid, ids[0], context=context).voucher_id.id
368         res = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'account_voucher', 'view_purchase_receipt_form')
369         result = {
370             'name': _('Expense Receipt'),
371             'view_type': 'form',
372             'view_mode': 'form',
373             'view_id': res and res[1] or False,
374             'res_model': 'account.voucher',
375             'type': 'ir.actions.act_window',
376             'nodestroy': True,
377             'target': 'current',
378             'res_id': voucher_id,
379         }
380         return result
381
382 hr_expense_expense()
383
384 class product_product(osv.osv):
385     _inherit = "product.product"
386     _columns = {
387         'hr_expense_ok': fields.boolean('Can be Expensed', help="Specify if the product can be selected in an HR expense line."),
388     }
389
390 product_product()
391
392 class hr_expense_line(osv.osv):
393     _name = "hr.expense.line"
394     _description = "Expense Line"
395
396     def _amount(self, cr, uid, ids, field_name, arg, context=None):
397         if not ids:
398             return {}
399         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),))
400         res = dict(cr.fetchall())
401         return res
402
403     def _get_uom_id(self, cr, uid, context=None):
404         result = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'product', 'product_uom_unit')
405         return result and result[1] or False
406
407     _columns = {
408         'name': fields.char('Expense Note', size=128, required=True),
409         'date_value': fields.date('Date', required=True),
410         'expense_id': fields.many2one('hr.expense.expense', 'Expense', ondelete='cascade', select=True),
411         'total_amount': fields.function(_amount, string='Total', digits_compute=dp.get_precision('Account')),
412         'unit_amount': fields.float('Unit Price', digits_compute=dp.get_precision('Product Price')),
413         'unit_quantity': fields.float('Quantities', digits_compute= dp.get_precision('Product Unit of Measure')),
414         'product_id': fields.many2one('product.product', 'Product', domain=[('hr_expense_ok','=',True)]),
415         'uom_id': fields.many2one('product.uom', 'Unit of Measure', required=True),
416         'description': fields.text('Description'),
417         'analytic_account': fields.many2one('account.analytic.account','Analytic account'),
418         'ref': fields.char('Reference', size=32),
419         'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of expense lines."),
420         }
421     _defaults = {
422         'unit_quantity': 1,
423         'date_value': lambda *a: time.strftime('%Y-%m-%d'),
424         'uom_id': _get_uom_id,
425     }
426     _order = "sequence, date_value desc"
427
428     def onchange_product_id(self, cr, uid, ids, product_id, context=None):
429         res = {}
430         if product_id:
431             product = self.pool.get('product.product').browse(cr, uid, product_id, context=context)
432             res['name'] = product.name
433             amount_unit = product.price_get('standard_price')[product.id]
434             res['unit_amount'] = amount_unit
435             res['uom_id'] = product.uom_id.id
436         return {'value': res}
437
438     def onchange_uom(self, cr, uid, ids, product_id, uom_id, context=None):
439         res = {'value':{}}
440         if not uom_id or not product_id:
441             return res
442         product = self.pool.get('product.product').browse(cr, uid, product_id, context=context)
443         uom = self.pool.get('product.uom').browse(cr, uid, uom_id, context=context)
444         if uom.category_id.id != product.uom_id.category_id.id:
445             res['warning'] = {'title': _('Warning'), 'message': _('Selected Unit of Measure does not belong to the same category as the product Unit of Measure')}
446             res['value'].update({'uom_id': product.uom_id.id})
447         return res
448
449 hr_expense_line()
450
451 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: