Skip to content
Permalink
Browse files

[REF] sale,sale_expense: clean and optimize reinvoice code

In the previous commit the analytic entries generation when posting a
move was optimized to be done in batch. The performance is break
when sale module is installed. Indeed, in case of reinvoicing a
analytic line, AAL creation will trigger a sale.line creation.

This mecanism is very inefficient because
1/ AAL creation and Sales line creation are not batched
2/ sale line creation is done after the AAL creation, and then linked
with a `write` operation. So one `create` and one `write` per AAL to
reinvoice.
3/ To determine on which Sales Order to reinvoice, many `search` can
be performed per AAL.

Also, this looks strange that AAL creation might result into a sales line
creation.

This commit changes this in order to optimize and clean the code:
- the account.move.line (that creates the AAL) will also create the
Sales lines
- SO lines creation will be done in batch
- minimize the number of `search` done during the process
- the SO line will be linked to the AAL by passing the SO line id in the
create values of AAL (no `write` operation one AAL and SOL are created).

This was the last part of legacy code of 'sale_analytic.py' that we need
to get rid of. There are still work to do, but I think now, the
business case handled here can now breathe and have a peacefull life.

Task-1911898
  • Loading branch information...
jem-odoo committed Nov 20, 2018
1 parent 7638c63 commit 6fa5e8ea1800cb18817b7e5ae280c806dc84d9b8
@@ -4,6 +4,7 @@
from . import analytic
from . import account_invoice
from . import account_reconciliation_widget
from . import account_move
from . import product_product
from . import product_template
from . import res_company
@@ -0,0 +1,185 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.

from odoo import api, fields, models, _
from odoo.exceptions import UserError


class AccountMoveLine(models.Model):
_inherit = 'account.move.line'

@api.multi
def _prepare_analytic_line(self):
""" Note: This method is called only on the move.line that having an analytic account, and
so that should create analytic entries.
"""
values_list = super(AccountMoveLine, self)._prepare_analytic_line()

# filter the move lines that can be reinvoiced: a cost (negative amount) analytic line without SO line but with a product can be reinvoiced
move_to_reinvoice = self.env['account.move.line']
for index, move_line in enumerate(self):
values = values_list[index]
if 'so_line' not in values and (move_line.credit or 0.0) <= (move_line.debit or 0.0) and move_line.product_id.expense_policy not in [False, 'no']:
move_to_reinvoice |= move_line

# insert the sale line in the create values of the analytic entries
if move_to_reinvoice:
map_sale_line_per_move = move_to_reinvoice._sale_create_reinvoice_sale_line()

for values in values_list:
sale_line = map_sale_line_per_move.get(values.get('move_id'))
if sale_line:
values['so_line'] = sale_line.id

return values_list

@api.multi
def _sale_create_reinvoice_sale_line(self):

sale_order_map = self._sale_determine_order()

sale_line_values_to_create = [] # the list of creation values of sale line to create.
existing_sale_line_cache = {} # in the sales_price-delivery case, we can reuse the same sale line. This cache will avoid doing a search each time the case happen
# `map_move_sale_line` is map where
# - key is the move line identifier
# - value is either a sale.order.line record (existing case), or an integer representing the index of the sale line to create in
# the `sale_line_values_to_create` (not existing case, which will happen more often than the first one).
map_move_sale_line = {}

for move_line in self:
sale_order = sale_order_map.get(move_line.id)

# no reinvoice as no sales order was found
if not sale_order:
continue

# raise if the sale order is not currenlty open
if sale_order.state != 'sale':
message_unconfirmed = _('The Sales Order %s linked to the Analytic Account %s must be validated before registering expenses.')
messages = {
'draft': message_unconfirmed,
'sent': message_unconfirmed,
'done': _('The Sales Order %s linked to the Analytic Account %s is currently locked. You cannot register an expense on a locked Sales Order. Please create a new SO linked to this Analytic Account.'),
'cancel': _('The Sales Order %s linked to the Analytic Account %s is cancelled. You cannot register an expense on a cancelled Sales Order.'),
}
raise UserError(messages[sale_order.state] % (sale_order.name, sale_order.analytic_account_id.name))

price = move_line._sale_get_invoice_price(sale_order)

# find the existing sale.line or keep its creation values to process this in batch
sale_line = None
if move_line.product_id.expense_policy == 'sales_price' and move_line.product_id.invoice_policy == 'delivery': # for those case only, we can try to reuse one
map_entry_key = (sale_order.id, move_line.product_id.id, price) # cache entry to limit the call to search
sale_line = existing_sale_line_cache.get(map_entry_key)
if sale_line: # already search, so reuse it. sale_line can be sale.order.line record or index of a "to create values" in `sale_line_values_to_create`
map_move_sale_line[move_line.id] = sale_line
existing_sale_line_cache[map_entry_key] = sale_line
else: # search for existing sale line
sale_line = self.env['sale.order.line'].search([
('order_id', '=', sale_order.id),
('price_unit', '=', price),
('product_id', '=', move_line.product_id.id),
('is_expense', '=', True),
], limit=1)
if sale_line: # found existing one, so keep the browse record
map_move_sale_line[move_line.id] = existing_sale_line_cache[map_entry_key] = sale_line
else: # should be create, so use the index of creation values instead of browse record
# save value to create it
sale_line_values_to_create.append(move_line._sale_prepare_sale_line_values(sale_order, price))
# store it in the cache of existing ones
existing_sale_line_cache[map_entry_key] = len(sale_line_values_to_create) - 1 # save the index of the value to create sale line
# store it in the map_move_sale_line map
map_move_sale_line[move_line.id] = len(sale_line_values_to_create) - 1 # save the index of the value to create sale line

else: # save its value to create it anyway
sale_line_values_to_create.append(move_line._sale_prepare_sale_line_values(sale_order, price))
map_move_sale_line[move_line.id] = len(sale_line_values_to_create) - 1 # save the index of the value to create sale line

# create the sale lines in batch
new_sale_lines = self.env['sale.order.line'].create(sale_line_values_to_create)

# build result map by replacing index with newly created record of sale.order.line
result = {}
for move_line_id, unknown_sale_line in map_move_sale_line.items():
if isinstance(unknown_sale_line, int): # index of newly created sale line
result[move_line_id] = new_sale_lines[unknown_sale_line]
elif isinstance(unknown_sale_line, models.BaseModel): # already record of sale.order.line
result[move_line_id] = unknown_sale_line
return result

@api.multi
def _sale_determine_order(self):
""" Get the mapping of move.line with the sale.order record on which its analytic entries should be reinvoiced
:return a dict where key is the move line id, and value is sale.order record (or None).
"""
analytic_accounts = self.mapped('analytic_account_id')

# link the analytic account with its open SO by creating a map: {AA.id: sale.order}, if we find some analytic accounts
mapping = {}
if analytic_accounts: # first, search for the open sales order
sale_orders = self.env['sale.order'].search([('analytic_account_id', 'in', analytic_accounts.ids), ('state', '=', 'sale')], order='create_date DESC')
for sale_order in sale_orders:
mapping[sale_order.analytic_account_id.id] = sale_order

analytic_accounts_without_open_order = analytic_accounts.filtered(lambda account: not mapping.get(account.id))
if analytic_accounts_without_open_order: # then, fill the blank with not open sales orders
sale_orders = self.env['sale.order'].search([('analytic_account_id', 'in', analytic_accounts_without_open_order.ids)], order='create_date DESC')
for sale_order in sale_orders:
mapping[sale_order.analytic_account_id.id] = sale_order

# map of AAL index with the SO on which it needs to be reinvoiced. Maybe be None if no SO found
return {move_line.id: mapping.get(move_line.analytic_account_id.id) for move_line in self}

@api.multi
def _sale_prepare_sale_line_values(self, order, price):
""" Generate the sale.line creation value from the current move line """
self.ensure_one()
last_so_line = self.env['sale.order.line'].search([('order_id', '=', order.id)], order='sequence desc', limit=1)
last_sequence = last_so_line.sequence + 1 if last_so_line else 100

fpos = order.fiscal_position_id or order.partner_id.property_account_position_id
taxes = fpos.map_tax(self.product_id.taxes_id, self.product_id, order.partner_id)

return {
'order_id': order.id,
'name': self.name,
'sequence': last_sequence,
'price_unit': price,
'tax_id': [x.id for x in taxes],
'discount': 0.0,
'product_id': self.product_id.id,
'product_uom': self.product_uom_id.id,
'product_uom_qty': 0.0,
'is_expense': True,
}

@api.multi
def _sale_get_invoice_price(self, order):
""" Based on the current move line, compute the price to reinvoice the analytic line that is going to be created (so the
price of the sale line).
"""
self.ensure_one()

unit_amount = self.quantity
amount = (self.credit or 0.0) - (self.debit or 0.0)

if self.product_id.expense_policy == 'sales_price':
return self.product_id.with_context(
partner=order.partner_id.id,
date_order=order.date_order,
pricelist=order.pricelist_id.id,
uom=self.product_uom_id.id
).price
if unit_amount == 0.0:
return 0.0

# Prevent unnecessary currency conversion that could be impacted by exchange rate
# fluctuations
if self.company_id.currency_id and amount and self.company_id.currency_id == order.currency_id:
return abs(amount / unit_amount)

price_unit = abs(amount / unit_amount)
currency_id = self.company_id.currency_id
if currency_id and currency_id != order.currency_id:
price_unit = currency_id._convert(price_unit, order.currency_id, order.company_id, order.date_order or fields.Date.today())
return price_unit
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.

from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo import fields, models


class AccountAnalyticLine(models.Model):
@@ -15,130 +14,3 @@ def _default_sale_line_domain(self):
return [('qty_delivered_method', '=', 'analytic')]

so_line = fields.Many2one('sale.order.line', string='Sales Order Item', domain=lambda self: self._default_sale_line_domain())

@api.model
def create(self, values):
result = super(AccountAnalyticLine, self).create(values)
if 'so_line' not in values and not result.so_line and result.product_id and result.product_id.expense_policy not in [False, 'no'] and result.amount <= 0: # allow to force a False value for so_line
result.sudo()._sale_determine_order_line()
return result

@api.multi
def write(self, values):
result = super(AccountAnalyticLine, self).write(values)
if 'so_line' not in values: # allow to force a False value for so_line
# only take the AAL from expense or vendor bill, meaning having a negative amount
self.sudo().filtered(lambda aal: not aal.so_line and aal.product_id and aal.product_id.expense_policy not in [False, 'no'] and aal.amount <= 0)._sale_determine_order_line()
return result

# ----------------------------------------------------------
# Vendor Bill / Expense : determine the Sale Order to reinvoice
# ----------------------------------------------------------

@api.multi
def _sale_get_invoice_price(self, order):
self.ensure_one()
if self.product_id.expense_policy == 'sales_price':
return self.product_id.with_context(
partner=order.partner_id,
date_order=order.date_order,
pricelist=order.pricelist_id.id,
uom=self.product_uom_id.id
).price
if self.unit_amount == 0.0:
return 0.0

# Prevent unnecessary currency conversion that could be impacted by exchange rate
# fluctuations
if self.currency_id and self.amount and self.currency_id == order.currency_id:
return abs(self.amount / self.unit_amount)

price_unit = abs(self.amount / self.unit_amount)
currency_id = self.company_id.currency_id
if currency_id and currency_id != order.currency_id:
price_unit = currency_id._convert(
price_unit, order.currency_id, order.company_id, order.date_order or fields.Date.today())
return price_unit

@api.multi
def _sale_prepare_sale_order_line_values(self, order, price):
self.ensure_one()
last_so_line = self.env['sale.order.line'].search([('order_id', '=', order.id)], order='sequence desc', limit=1)
last_sequence = last_so_line.sequence + 1 if last_so_line else 100

fpos = order.fiscal_position_id or order.partner_id.property_account_position_id
taxes = fpos.map_tax(self.product_id.taxes_id, self.product_id, order.partner_id)

return {
'order_id': order.id,
'name': self.name,
'sequence': last_sequence,
'price_unit': price,
'tax_id': [x.id for x in taxes],
'discount': 0.0,
'product_id': self.product_id.id,
'product_uom': self.product_uom_id.id,
'product_uom_qty': 0.0,
'is_expense': True,
}

@api.multi
def _sale_determine_order(self):
mapping = {}
for analytic_line in self:
sale_order = self.env['sale.order'].search([('analytic_account_id', '=', analytic_line.account_id.id), ('state', '=', 'sale')], limit=1)
if not sale_order:
sale_order = self.env['sale.order'].search([('analytic_account_id', '=', analytic_line.account_id.id)], limit=1)
if not sale_order:
continue
mapping[analytic_line.id] = sale_order
return mapping

@api.multi
def _sale_determine_order_line(self):
""" Automatically set the SO line on the analytic line, for the expense/vendor bills flow. It retrives
an existing line, or create a new one (upselling expenses).
"""
# determine SO : first SO open linked to AA
sale_order_map = self._sale_determine_order()
# determine so line
value_to_write = {}
for analytic_line in self:
sale_order = sale_order_map.get(analytic_line.id)
if not sale_order:
continue

if sale_order.state != 'sale':
message_unconfirmed = _('The Sales Order %s linked to the Analytic Account %s must be validated before registering expenses.')
messages = {
'draft': message_unconfirmed,
'sent': message_unconfirmed,
'done': _('The Sales Order %s linked to the Analytic Account %s is currently locked. You cannot register an expense on a locked Sales Order. Please create a new SO linked to this Analytic Account.'),
'cancel': _('The Sales Order %s linked to the Analytic Account %s is cancelled. You cannot register an expense on a cancelled Sales Order.'),
}
raise UserError(messages[sale_order.state] % (sale_order.name, analytic_line.account_id.name))

so_line = None
price = analytic_line._sale_get_invoice_price(sale_order)
if analytic_line.product_id.expense_policy == 'sales_price' and analytic_line.product_id.invoice_policy == 'delivery':
so_line = self.env['sale.order.line'].search([
('order_id', '=', sale_order.id),
('price_unit', '=', price),
('product_id', '=', self.product_id.id),
('is_expense', '=', True),
], limit=1)

if not so_line:
# generate a new SO line
so_line_values = analytic_line._sale_prepare_sale_order_line_values(sale_order, price)
so_line = self.env['sale.order.line'].create(so_line_values)
so_line._compute_tax_id()

if so_line: # if so line found or created, then update AAL (this will trigger the recomputation of qty delivered on SO line)
value_to_write.setdefault(so_line.id, self.env['account.analytic.line'])
value_to_write[so_line.id] |= analytic_line

# write so line on (maybe) multiple AAL to trigger only one read_group per SO line
for so_line_id, analytic_lines in value_to_write.items():
if analytic_lines:
analytic_lines.write({'so_line': so_line_id})
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.

from . import analytic
from . import hr_expense
from . import sale_order
from . import product_template
from . import account_move
@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.

from odoo import api, models


class AccountMoveLine(models.Model):
_inherit = 'account.move.line'

@api.multi
def _sale_determine_order(self):
""" For move lines created from expense, we override the normal behavior.
Note: if no SO but an AA is given on the expense, we will determine anyway the SO from the AA, using the same
mecanism as in Vendor Bills.
"""
mapping_from_invoice = super(AccountMoveLine, self)._sale_determine_order()

mapping_from_expense = {}
for move_line in self.filtered(lambda move_line: move_line.expense_id):
if move_line.expense_id.sale_order_id:
mapping_from_expense[move_line.id] = move_line.expense_id.sale_order_id or None

mapping_from_invoice.update(mapping_from_expense)
return mapping_from_invoice

This file was deleted.

Oops, something went wrong.
Oops, something went wrong.

0 comments on commit 6fa5e8e

Please sign in to comment.
You can’t perform that action at this time.