# -*- coding: utf-8 -*- # © 2014-2017 Akretion (http://www.akretion.com) # @author Alexis de Lattre # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). from odoo import models, fields, api, _ from odoo.exceptions import UserError, ValidationError from odoo.tools import float_compare, float_is_zero import odoo.addons.decimal_precision as dp class ProductTemplate(models.Model): _inherit = 'product.template' # same code in class product.product and product.template @api.onchange('can_be_expensed') def onchange_can_be_expensed(self): if self.can_be_expensed: unit_uom = self.env.ref('product.product_uom_unit') self.type = 'service' self.list_price = 0.0 self.sale_ok = False self.purchase_ok = False self.uom_id = unit_uom.id self.po_uom_id = unit_uom.id self.taxes_id = False # It probably doesn't make sense to have a constraint on a property fields # But the same constrain is also on hr.expense @api.constrains('supplier_taxes_id', 'can_be_expensed') def _check_expense_product(self): for product in self: if product.can_be_expensed and product.supplier_taxes_id: if len(product.supplier_taxes_id) > 1: raise ValidationError(_( "The module hr_expense_usability only supports one " "tax for expense products. The product '%s' has " "more than one tax.") % product.display_name) if not product.supplier_taxes_id[0].price_include: raise ValidationError(_( "The module hr_expense_usability only supports " "taxes with the property 'Included in Price' for " "expense products. The tax '%s' on the product '%s' " "is not 'Included in Price'.") % ( product.supplier_taxes_id[0].name, product.display_name)) class ProductProduct(models.Model): _inherit = 'product.product' # same code in class product.product and product.template @api.onchange('can_be_expensed') def onchange_can_be_expensed(self): if self.can_be_expensed: unit_uom = self.env.ref('product.product_uom_unit') self.type = 'service' self.list_price = 0.0 self.sale_ok = False self.purchase_ok = False self.uom_id = unit_uom.id self.po_uom_id = unit_uom.id self.taxes_id = False class HrEmployee(models.Model): _inherit = 'hr.employee' def _get_accounting_partner_from_employee(self): # By default, odoo uses self.employee_id.address_home_id # which users usually don't configure # (even demo data doesn't bother to set it...) # So I decided to put a fallback on employee.user_id.partner_id self.ensure_one() if self.address_home_id: partner = self.address_home_id.commercial_partner_id elif self.user_id: # We don't use "commercial partner" here... partner = self.user_id.partner_id else: raise UserError(_( "The employee '%s' doesn't have a Home Address and isn't " "linked to an Odoo user. You have to set one of these two " "fields on the employee form in order to get a partner from " "the employee for the Journal Items.") % self.display_name) return partner class HrExpense(models.Model): _inherit = 'hr.expense' employee_id = fields.Many2one(track_visibility='onchange') date = fields.Date(track_visibility='onchange', required=True) currency_id = fields.Many2one(track_visibility='onchange', required=True) total_amount = fields.Float(track_visibility='onchange') # I want a specific precision for unit_amount of expense # main reason is KM cost which is 3 by default unit_amount = fields.Float(digits=dp.get_precision('Expense Unit Price')) tax_amount = fields.Monetary( string='Tax Amount', currency_field='currency_id', readonly=True, states={'draft': [('readonly', False)]}) untaxed_amount_usability = fields.Monetary( string='Untaxed Amount', currency_field='currency_id', readonly=True, states={'draft': [('readonly', False)]}) company_currency_id = fields.Many2one( related='company_id.currency_id', readonly=True, store=True) total_amount_company_currency = fields.Monetary( compute='compute_amount_company_currency', readonly=True, store=True, string='Total in Company Currency', currency_field='company_currency_id') untaxed_amount_company_currency = fields.Monetary( compute='compute_amount_company_currency', readonly=True, store=True, string='Untaxed Amount in Company Currency', currency_field='company_currency_id') tax_amount_company_currency = fields.Monetary( compute='compute_amount_company_currency', readonly=True, store=True, string='Tax Amount in Company Currency', currency_field='company_currency_id') # I don't use the native field 'untaxed_amount' (computed, store=True) @api.depends( 'currency_id', 'company_id', 'total_amount', 'date', 'untaxed_amount_usability') def compute_amount_company_currency(self): for exp in self: date = exp.date if exp.currency_id and exp.company_id: src_currency = exp.currency_id.with_context(date=date) dest_currency = exp.company_id.currency_id total_cc = src_currency.compute( exp.total_amount, dest_currency) untaxed_cc = src_currency.compute( exp.untaxed_amount_usability, dest_currency) exp.total_amount_company_currency = total_cc exp.untaxed_amount_company_currency = untaxed_cc exp.tax_amount_company_currency = total_cc - untaxed_cc @api.onchange('untaxed_amount_usability') def untaxed_amount_usability_change(self): self.tax_amount = self.total_amount - self.untaxed_amount_usability @api.onchange('tax_amount') def tax_amount_change(self): self.untaxed_amount_usability = self.total_amount - self.tax_amount @api.onchange('unit_amount', 'quantity', 'tax_ids') def total_amount_change(self): total = self.unit_amount * self.quantity if self.tax_ids: res = self.tax_ids.compute_all( self.unit_amount, currency=self.currency_id, quantity=self.quantity, product=self.product_id) self.untaxed_amount_usability = res['total_excluded'] self.amount_tax = total - res['total_excluded'] else: self.untaxed_amount_usability = total self.tax_amount = False @api.constrains( 'product_id', 'payment_mode', 'tax_ids', 'untaxed_amount_usability', 'tax_amount', 'quantity', 'unit_amount') def _check_expense(self): for exp in self: if exp.tax_ids: if len(exp.tax_ids) > 1: raise ValidationError(_( "The expense '%s' has several taxes. The module " "'hr_expense_usability' only supports one " "tax on expenses.") % exp.name) if not exp.tax_ids[0].price_include: raise ValidationError(_( "The expense '%s' has a tax that doesn't have the " "property 'Included in Price'. The module " "'hr_expense_usability' only accepts taxes included " "in price (to avoid confusing employees).") % exp.name) # field is hidden and default value is 'own_account', so # it should never happen if exp.payment_mode == 'company_account': raise ValidationError(_( "Support for 'Payment By Company' is removed " "by the module hr_expense_usability.")) prec = exp.currency_id.rounding if float_compare( exp.total_amount, exp.tax_amount + exp.untaxed_amount_usability, precision_rounding=prec): raise ValidationError(_( "The expense '%s' has a total amount (%s) which is " "different from the sum of the untaxed amount (%s) " "and the tax amount (%s).") % ( exp.name, exp.total_amount, exp.untaxed_amount_usability, exp.tax_amount)) if ( not float_is_zero( exp.tax_amount, precision_rounding=prec) and not exp.tax_ids): raise ValidationError(_( "The amount tax of expense '%s' is %s, " "but no tax is selected.") % (exp.name, exp.tax_amount)) sign = { 'untaxed_amount_usability': 0, 'tax_amount': 0, 'total_amount': 0, } for field_name in sign.iterkeys(): sign[field_name] = float_compare( exp[field_name], 0, precision_rounding=prec) if ( sign['total_amount'] < 0 and ( sign['untaxed_amount_usability'] > 0 or sign['tax_amount'] > 0)): raise ValidationError(_( "On the expense '%s', the total amount (%s) is " "negative, so the untaxed amount (%s) and the " "tax amount (%s) should be negative or null.") % ( exp.name, exp.total_amount, exp.untaxed_amount_usability, exp.tax_amount)) if ( sign['total_amount'] > 0 and ( sign['untaxed_amount_usability'] < 0 or sign['tax_amount'] < 0)): raise ValidationError(_( "On the expense '%s', the total amount (%s) is " "positive, so the untaxed amount (%s) and the " "tax amount (%s) should be positive or null.") % ( exp.name, exp.total_amount, exp.untaxed_amount_usability, exp.tax_amount)) def action_move_create(self): '''disable account.move creation per hr.expense''' raise UserError(_( "The method 'action_move_create' is blocked by the module " "'hr_expense_usability'")) class HrExpenseSheet(models.Model): _inherit = 'hr.expense.sheet' name = fields.Char(track_visibility='onchange') employee_id = fields.Many2one(track_visibility='onchange') responsible_id = fields.Many2one(track_visibility='onchange') accounting_date = fields.Date(track_visibility='onchange') company_currency_id = fields.Many2one( related='company_id.currency_id', readonly=True, store=True) total_amount_company_currency = fields.Monetary( compute='compute_total_company_currency', currency_field='company_currency_id', readonly=True, store=True, string='Total', help="Total amount (with taxes) in company currency") untaxed_amount_company_currency = fields.Monetary( compute='compute_total_company_currency', currency_field='company_currency_id', readonly=True, store=True, string='Untaxed Amount', help="Untaxed amount in company currency") tax_amount_company_currency = fields.Monetary( compute='compute_total_company_currency', currency_field='company_currency_id', readonly=True, store=True, string='Tax Amount', help="Tax amount in company currency") account_move_id = fields.Many2one( ondelete='restrict') @api.depends( 'expense_line_ids.total_amount_company_currency', 'expense_line_ids.untaxed_amount_company_currency') def compute_total_company_currency(self): for sheet in self: total = 0.0 untaxed = 0.0 for line in sheet.expense_line_ids: total += line.total_amount_company_currency untaxed += line.untaxed_amount_company_currency sheet.total_amount_company_currency = total sheet.untaxed_amount_company_currency = untaxed sheet.tax_amount_company_currency = total - untaxed @api.one @api.constrains('expense_line_ids') def _check_amounts(self): '''Remove the constraint 'You cannot have a positive and negative amounts on the same expense report.' ''' return True def _prepare_move(self): self.ensure_one() if not self.journal_id: raise UserError(_( "No journal selected for expense report %s.") % self.display_name) date = self.accounting_date or fields.Date.context_today(self) vals = { 'name': '/', 'journal_id': self.journal_id.id, 'date': date, 'ref': self.number, 'company_id': self.company_id.id, 'line_ids': [], } return vals def _prepare_payable_move_line(self, total_company_currency): self.ensure_one() debit = credit = False prec = self.company_id.currency_id.rounding if float_compare( total_company_currency, 0, precision_rounding=prec) > 0: credit = total_company_currency else: debit = total_company_currency * -1 partner = self.employee_id._get_accounting_partner_from_employee() # by default date_maturity = move date vals = { 'account_id': partner.property_account_payable_id.id, 'partner_id': partner.id, 'name': self.name[:64], 'credit': credit, 'debit': debit, } return vals def _prepare_expense_move_lines(self): self.ensure_one() mlines = [] partner = self.employee_id._get_accounting_partner_from_employee() prec = self.company_id.currency_id.rounding for exp in self.expense_line_ids: # Expense if exp.account_id: account = exp.account_id else: account = exp.product_id.product_tmpl_id.\ _get_product_accounts()['expense'] if not account: raise UserError(_( "No expense account found for product '%s' nor " "for it's related product category.") % ( exp.product_id.display_name, exp.product_id.categ_id.display_name)) mlines.append({ 'type': 'expense', 'partner_id': partner.id, 'account_id': account.id, 'analytic_account_id': exp.analytic_account_id.id or False, 'amount': exp.untaxed_amount_company_currency, 'name': exp.employee_id.name + ': ' + exp.name.split('\n')[0][:64], 'product_id': exp.product_id.id, 'product_uom_id': exp.product_uom_id.id, 'quantity': exp.quantity, }) # TAX tax_cmp = float_compare( exp.tax_amount_company_currency, 0, precision_rounding=prec) if tax_cmp: tax = exp.tax_ids[0] # there is a constrain on this if tax_cmp > 0: tax_account_id = tax.account_id.id else: tax_account_id = tax.refund_account_id.id if tax.analytic: analytic_account_id = exp.analytic_account_id.id or False else: analytic_account_id = False mlines.append({ 'type': 'tax', 'partner_id': partner.id, 'account_id': tax_account_id, 'analytic_account_id': analytic_account_id, 'amount': exp.tax_amount_company_currency, 'name': exp.name.split('\n')[0][:64], }) # grouping group_mlines = {} group = self.journal_id.group_invoice_lines i = 0 for mline in mlines: i += 1 if group: key = ( mline['type'], mline['account_id'], mline['analytic_account_id'], False) else: key = (False, False, False, i) if key in group_mlines: group_mlines[key]['amount'] += mline['amount'] group_mlines[key]['name'] = self.name[:64] group_mlines[key]['quantity'] += mline['quantity'] if 'product_id' in group_mlines[key] and \ group_mlines[key]['product_id'] != \ mline['product_id']: del group_mlines[key]['product_id'] if 'product_uom_id' in group_mlines[key] and \ group_mlines[key]['product_uom_id'] != \ mline['product_uom_id']: del group_mlines[key]['product_uom_id'] else: group_mlines[key] = mline res_mlines = [] total_cc = 0.0 for gmlines in group_mlines.itervalues(): total_cc += gmlines['amount'] credit = debit = False cmp_amount = float_compare( gmlines['amount'], 0, precision_rounding=prec) if cmp_amount > 0: debit = gmlines['amount'] elif cmp_amount < 0: credit = gmlines['amount'] * -1 else: continue res_mlines.append((0, 0, { 'partner_id': gmlines['partner_id'], 'account_id': gmlines['account_id'], 'analytic_account_id': gmlines['analytic_account_id'], 'product_id': gmlines.get('product_id', False), 'product_uom_id': gmlines.get('product_uom_id', False), 'quantity': gmlines.get('quantity', 1), 'name': gmlines['name'], 'debit': debit, 'credit': credit, })) return res_mlines, total_cc def action_sheet_move_create(self): for sheet in self: if sheet.state != 'approve': raise UserError(_( "It is possible to generate accounting entries only " "for approved expense reports. The expense report %s " "is in state '%s'.") % (sheet.number, sheet.state)) if float_is_zero( sheet.total_amount, precision_rounding=sheet.company_id.currency_id.rounding): raise UserError(_( "The expense report %s has a total amount of 0.") % sheet.number) vals = sheet._prepare_move() exp_mlvals_list, total_cc = self._prepare_expense_move_lines() vals['line_ids'] += exp_mlvals_list pay_mlvals = sheet._prepare_payable_move_line(total_cc) vals['line_ids'].append((0, 0, pay_mlvals)) move = self.env['account.move'].create(vals) sheet.write(sheet._prepare_sheet_write_move_create(move)) def _prepare_sheet_write_move_create(self, move): self.ensure_one() vals = { 'state': 'post', 'account_move_id': move.id, } if not self.accounting_date: vals['accounting_date'] = move.date return vals # TODO: for multi-company with expenses envir., we would need a field # 'default_expense_journal' on company (otherwise, it takes the # first purchase journal, which is probably not the good one