Files
odoo-usability/hr_expense_usability/hr_expense.py
Stéphane Bidoul (ACSONE) 55d977914e [10.0] hr_expense_usability improvements (#37)
* [FIX] hr_expense_usability: set default move number

Otherwise it's not possible to post the account move.

* [FIX] hr_expense_usability: prevent deleting draft move

This prevents deleting a draft account move that is linked
to an expense. Without this the expense could stay in posted
while the related move has been deleted, which is inconsitent
and impossible to recover for the user.

* [IMP] hr_expense_usability: preserve product/quantity on move line

When possible, preserve product and quantity on move line. This is
important when generating analytic lines to reinvoice expenses

* add contributor

* [FIX] hr_expense_usability: readonly account move on expense sheet

* [IMP] hr_expense_usablility: preserve employee name in move line name

This is important when reinvoicing expenses, so the correct label
is visible on the sale order.
2017-07-01 14:39:08 +02:00

465 lines
20 KiB
Python

# -*- coding: utf-8 -*-
# © 2014-2017 Akretion (http://www.akretion.com)
# @author Alexis de Lattre <alexis.delattre@akretion.com>
# 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