[REF] stock: perf orderpoint as batch

Currently orderpoint run one request at a time. However it's not
efficient since compute are some private methods could be executed
one time in batch instead of record by record.

The strategy in order to improve it is:
- Still cut the orderpoints in batch of 1000 (usefull when using a new
cursor with intermediate commit)
- For each batch of 1000, create a list of procurement values and run
them in batch (commit a2a6b099a4 for more info)
- Each time a procurement exception is raised, catch the failing
orderpoint and rollback to the state before run. Remove the failing
order point from the current set and run it again.
- run method try to catch a maximum  errors by continuing to process even it
finds error in order to stack them. It would avoid to run the same set
again and again each time an error is raised.

test_orderpoint_1 took 975s before this patch and 100s after.

Task: 1849912
This commit is contained in:
Arnold Moyaux
2020-01-09 15:59:12 +00:00
parent 2e747cedf4
commit bdc2280309
5 changed files with 183 additions and 99 deletions
+5 -1
View File
@@ -37,14 +37,18 @@ class StockRule(models.Model):
@api.model
def _run_manufacture(self, procurements):
productions_values_by_company = defaultdict(list)
errors = []
for procurement, rule in procurements:
bom = self._get_matching_bom(procurement.product_id, procurement.company_id, procurement.values)
if not bom:
msg = _('There is no Bill of Material of type manufacture or kit found for the product %s. Please define a Bill of Material for this product.') % (procurement.product_id.display_name,)
raise ProcurementException([(procurement, msg)])
errors.append((procurement, msg))
productions_values_by_company[procurement.company_id.id].append(rule._prepare_mo_vals(*procurement, bom))
if errors:
raise ProcurementException(errors)
for company_id, productions_values in productions_values_by_company.items():
# create the MO as SUPERUSER because the current user may not have the rights to do it (mto product launched by a sale for example)
productions = self.env['mrp.production'].sudo().with_company(company_id).create(productions_values)
+5 -1
View File
@@ -40,6 +40,7 @@ class StockRule(models.Model):
@api.model
def _run_buy(self, procurements):
procurements_by_po_domain = defaultdict(list)
errors = []
for procurement, rule in procurements:
# Get the schedule date in order to find a valid seller
@@ -53,7 +54,7 @@ class StockRule(models.Model):
if not supplier:
msg = _('There is no matching vendor price to generate the purchase order for product %s (no vendor defined, minimum quantity not reached, dates not valid, ...). Go on the product form and complete the list of vendors.') % (procurement.product_id.display_name)
raise ProcurementException([(procurement, msg)])
errors.append((procurement, msg))
partner = supplier.name
# we put `supplier_info` in values for extensibility purposes
@@ -66,6 +67,9 @@ class StockRule(models.Model):
domain = rule._make_po_get_domain(procurement.company_id, procurement.values, partner)
procurements_by_po_domain[domain].append((procurement, rule))
if errors:
raise ProcurementException(errors)
for domain, procurements_rules in procurements_by_po_domain.items():
# Get the procurements for the current domain.
# Get the rules for the current domain. Their only use is to create
+9
View File
@@ -117,6 +117,12 @@ class StockWarehouseOrderpoint(models.Model):
raise UserError(_("Changing the company of this record is forbidden at this point, you should rather archive it and create a new one."))
return super().write(vals)
def _get_product_context(self):
"""Used to call `virtual_available` when running an orderpoint."""
self.ensure_one()
return {
'location': self.location_id.id,
}
def _prepare_procurement_values(self, product_qty, date=False, group=False):
""" Prepare specific key for moves or other components that will be created from a stock rule
@@ -130,3 +136,6 @@ class StockWarehouseOrderpoint(models.Model):
'orderpoint_id': self,
'group_id': group or self.group_id,
}
def _post_process_scheduler(self):
return True
+76 -97
View File
@@ -1,14 +1,15 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import namedtuple, OrderedDict, defaultdict
from collections import namedtuple, defaultdict
from datetime import datetime, time
from dateutil.relativedelta import relativedelta
from odoo.tools.misc import split_every
from psycopg2 import OperationalError
from odoo import api, fields, models, registry, SUPERUSER_ID, _
from odoo.osv import expression
from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, float_compare, float_round
from odoo.tools import float_compare, float_round, frozendict
from odoo.exceptions import UserError
import logging
@@ -364,10 +365,19 @@ class ProcurementGroup(models.Model):
@api.model
def run(self, procurements, raise_user_error=True):
""" Method used in a procurement case. The purpose is to supply the
product passed as argument in the location also given as an argument.
In order to be able to find a suitable location that provide the product
it will search among stock.rule.
"""Fulfil `procurements` with the help of stock rules.
Procurements are needs of products at a certain location. To fulfil
these needs, we need to create some sort of documents (`stock.move`
by default, but extensions of `_run_` methods allow to create every
type of documents).
:param procurements: the description of the procurement
:type list: list of `~odoo.addons.stock.models.stock_rule.ProcurementGroup.Procurement`
:param raise_user_error: will raise either an UserError or a ProcurementException
:type raise_user_error: boolan, optional
:raises UserError: if `raise_user_error` is True and a procurement isn't fulfillable
:raises ProcurementException: if `raise_user_error` is False and a procurement isn't fulfillable
"""
def raise_exception(procurement_errors):
@@ -506,21 +516,7 @@ class ProcurementGroup(models.Model):
@api.model
def _procurement_from_orderpoint_get_order(self):
return 'location_id'
@api.model
def _procurement_from_orderpoint_get_grouping_key(self, orderpoint_ids):
orderpoints = self.env['stock.warehouse.orderpoint'].browse(orderpoint_ids)
return orderpoints.location_id.id
@api.model
def _procurement_from_orderpoint_get_groups(self, orderpoint_ids):
""" Make groups for a given orderpoint; by default schedule all operations in one without date """
return [{'to_date': False, 'procurement_values': dict()}]
@api.model
def _procurement_from_orderpoint_post_process(self, orderpoint_ids):
return True
return 'location_id,company_id'
def _get_orderpoint_domain(self, company_id=False):
domain = [('company_id', '=', company_id)] if company_id else []
@@ -535,97 +531,80 @@ class ProcurementGroup(models.Model):
This is appropriate for batch jobs only.
"""
self = self.with_company(company_id)
OrderPoint = self.env['stock.warehouse.orderpoint']
domain = self._get_orderpoint_domain(company_id=company_id)
orderpoints_noprefetch = OrderPoint.with_context(prefetch_fields=False).search(domain,
order=self._procurement_from_orderpoint_get_order()).ids
while orderpoints_noprefetch:
orderpoints_noprefetch = self.env['stock.warehouse.orderpoint'].search_read(
domain, fields=['id'], order=self._procurement_from_orderpoint_get_order())
orderpoints_noprefetch = [orderpoint['id'] for orderpoint in orderpoints_noprefetch]
for orderpoints_batch in split_every(1000, orderpoints_noprefetch):
if use_new_cursor:
cr = registry(self._cr.dbname).cursor()
self = self.with_env(self.env(cr=cr))
OrderPoint = self.env['stock.warehouse.orderpoint']
orderpoints_batch = self.env['stock.warehouse.orderpoint'].browse(orderpoints_batch)
orderpoints_exceptions = []
while orderpoints_batch:
orderpoints = OrderPoint.browse(orderpoints_noprefetch[:1000])
orderpoints_noprefetch = orderpoints_noprefetch[1000:]
# Calculate groups that can be executed together
orderpoints_contexts = defaultdict(lambda: self.env['stock.warehouse.orderpoint'])
# Calculate groups that can be executed together
location_data = OrderedDict()
procurements = []
for orderpoint in orderpoints_batch:
orderpoint_context = orderpoint._get_product_context()
product_context = frozendict({**self.env.context, **orderpoint_context})
orderpoints_contexts[product_context] |= orderpoint
def makedefault():
return {
'products': self.env['product.product'],
'orderpoints': self.env['stock.warehouse.orderpoint'],
'groups': []
}
for orderpoint_context, orderpoints_by_context in orderpoints_contexts.items():
substract_quantity = orderpoints_by_context._quantity_in_progress()
product_quantity = orderpoints_by_context.product_id.with_context(orderpoint_context)._product_available()
for orderpoint in orderpoints:
key = self._procurement_from_orderpoint_get_grouping_key([orderpoint.id])
if not location_data.get(key):
location_data[key] = makedefault()
location_data[key]['products'] += orderpoint.product_id
location_data[key]['orderpoints'] += orderpoint
location_data[key]['groups'] = self._procurement_from_orderpoint_get_groups([orderpoint.id])
for orderpoint in orderpoints_by_context:
op_product_virtual = product_quantity[orderpoint.product_id.id]['virtual_available']
if op_product_virtual is None:
continue
if float_compare(op_product_virtual, orderpoint.product_min_qty, precision_rounding=orderpoint.product_uom.rounding) <= 0:
qty = max(orderpoint.product_min_qty, orderpoint.product_max_qty) - op_product_virtual
remainder = orderpoint.qty_multiple > 0 and qty % orderpoint.qty_multiple or 0.0
for location_id, location_data in location_data.items():
location_orderpoints = location_data['orderpoints']
product_context = dict(self._context, location=location_orderpoints[0].location_id.id)
substract_quantity = location_orderpoints._quantity_in_progress()
if float_compare(remainder, 0.0, precision_rounding=orderpoint.product_uom.rounding) > 0:
qty += orderpoint.qty_multiple - remainder
for group in location_data['groups']:
if group.get('from_date'):
product_context['from_date'] = group['from_date'].strftime(DEFAULT_SERVER_DATETIME_FORMAT)
if group['to_date']:
product_context['to_date'] = group['to_date'].strftime(DEFAULT_SERVER_DATETIME_FORMAT)
product_quantity = location_data['products'].with_context(product_context)._product_available()
for orderpoint in location_orderpoints:
try:
op_product_virtual = product_quantity[orderpoint.product_id.id]['virtual_available']
if op_product_virtual is None:
if float_compare(qty, 0.0, precision_rounding=orderpoint.product_uom.rounding) < 0:
continue
if float_compare(op_product_virtual, orderpoint.product_min_qty, precision_rounding=orderpoint.product_uom.rounding) <= 0:
qty = max(orderpoint.product_min_qty, orderpoint.product_max_qty) - op_product_virtual
remainder = orderpoint.qty_multiple > 0 and qty % orderpoint.qty_multiple or 0.0
if float_compare(remainder, 0.0, precision_rounding=orderpoint.product_uom.rounding) > 0:
qty += orderpoint.qty_multiple - remainder
qty -= substract_quantity[orderpoint.id]
qty_rounded = float_round(qty, precision_rounding=orderpoint.product_uom.rounding)
if qty_rounded > 0:
date = datetime.combine(orderpoint_context.get('to_date'), time.min)
values = orderpoint._prepare_procurement_values(qty_rounded, date=date)
procurements.append(self.env['procurement.group'].Procurement(
orderpoint.product_id, qty_rounded, orderpoint.product_uom,
orderpoint.location_id, orderpoint.name, orderpoint.name,
orderpoint.company_id, values))
if float_compare(qty, 0.0, precision_rounding=orderpoint.product_uom.rounding) < 0:
continue
try:
with self.env.cr.savepoint():
self.env['procurement.group'].with_context(from_orderpoint=True).run(procurements, raise_user_error=False)
except ProcurementException as errors:
for procurement, error_msg in errors.procurement_exceptions:
orderpoints_exceptions += [(procurement.values.get('orderpoint_id'), error_msg)]
failed_orderpoints = self.env['stock.warehouse.orderpoint'].concat(*[o[0] for o in orderpoints_exceptions])
if not failed_orderpoints:
_logger.error('Unable to process orderpoints')
break
orderpoints_batch -= failed_orderpoints
qty -= substract_quantity[orderpoint.id]
qty_rounded = float_round(qty, precision_rounding=orderpoint.product_uom.rounding)
if qty_rounded > 0:
values = orderpoint._prepare_procurement_values(qty_rounded, **group['procurement_values'])
try:
with self._cr.savepoint():
#TODO: make it batch
self.env['procurement.group'].with_context(from_orderpoint=True).run([self.env['procurement.group'].Procurement(
orderpoint.product_id, qty_rounded, orderpoint.product_uom,
orderpoint.location_id, orderpoint.name, orderpoint.name,
orderpoint.company_id, values)], raise_user_error=False)
except UserError as error:
self.env['stock.rule']._log_next_activity(orderpoint.product_id, error.name)
self._procurement_from_orderpoint_post_process([orderpoint.id])
if use_new_cursor:
cr.commit()
except OperationalError:
if use_new_cursor:
orderpoints_noprefetch += [orderpoint.id]
cr.rollback()
continue
else:
raise
try:
if use_new_cursor:
cr.commit()
except OperationalError:
if use_new_cursor:
cr.rollback()
continue
except OperationalError:
if use_new_cursor:
cr.rollback()
continue
else:
raise
else:
raise
orderpoints_batch._post_process_scheduler()
break
for orderpoint, error_msg in orderpoints_exceptions:
self.env['stock.rule']._log_next_activity(orderpoint.product_id, error_msg)
if use_new_cursor:
cr.commit()
+88
View File
@@ -166,3 +166,91 @@ class TestProcRule(TransactionCase):
self.assertAlmostEqual(move_orig.date_expected, move_orig_initial_date, delta=timedelta(seconds=10), msg='schedule date should not be impacted by action_done')
self.assertAlmostEqual(move_orig.date, datetime.now(), delta=timedelta(seconds=10), msg='date should be now')
self.assertAlmostEqual(move_dest.date_expected, move_dest_initial_date + timedelta(days=6), delta=timedelta(seconds=10), msg='date should be propagated')
class TestProcRuleLoad(TransactionCase):
def setUp(cls):
super(TestProcRuleLoad, cls).setUp()
cls.skipTest("Performance test, too heavy to run.")
def test_orderpoint_1(self):
""" Try 500 products with a 1000 RR(stock -> shelf1 and stock -> shelf2)
Also randomly include 4 miss configuration.
"""
warehouse = self.env['stock.warehouse'].create({
'name': 'Test Warehouse',
'code': 'TWH'
})
warehouse.reception_steps = 'three_steps'
supplier_loc = self.env.ref('stock.stock_location_suppliers')
stock_loc = warehouse.lot_stock_id
shelf1 = self.env['stock.location'].create({
'location_id': stock_loc.id,
'usage': 'internal',
'name': 'shelf1'
})
shelf2 = self.env['stock.location'].create({
'location_id': stock_loc.id,
'usage': 'internal',
'name': 'shelf2'
})
products = self.env['product.product'].create([{'name': i, 'type': 'product'} for i in range(500)])
self.env['stock.warehouse.orderpoint'].create([{
'product_id': products[i // 2].id,
'location_id': (i % 2 == 0) and shelf1.id or shelf2.id,
'warehouse_id': warehouse.id,
'product_min_qty': 5,
'product_max_qty': 10,
} for i in range(1000)])
self.env['stock.rule'].create({
'name': 'Rule Shelf1',
'route_id': warehouse.reception_route_id.id,
'location_id': shelf1.id,
'location_src_id': stock_loc.id,
'action': 'pull',
'procure_method': 'make_to_order',
'picking_type_id': warehouse.int_type_id.id,
})
self.env['stock.rule'].create({
'name': 'Rule Shelf2',
'route_id': warehouse.reception_route_id.id,
'location_id': shelf2.id,
'location_src_id': stock_loc.id,
'action': 'pull',
'procure_method': 'make_to_order',
'picking_type_id': warehouse.int_type_id.id,
})
self.env['stock.rule'].create({
'name': 'Rule Supplier',
'route_id': warehouse.reception_route_id.id,
'location_id': warehouse.wh_input_stock_loc_id.id,
'location_src_id': supplier_loc.id,
'action': 'pull',
'procure_method': 'make_to_stock',
'picking_type_id': warehouse.in_type_id.id,
})
wrong_route = self.env['stock.location.route'].create({
'name': 'Wrong Route',
})
self.env['stock.rule'].create({
'name': 'Trap Rule',
'route_id': wrong_route.id,
'location_id': warehouse.wh_input_stock_loc_id.id,
'location_src_id': supplier_loc.id,
'action': 'pull',
'procure_method': 'make_to_order',
'picking_type_id': warehouse.in_type_id.id,
})
(products[50] | products[99] | products[150] | products[199]).write({
'route_ids': [(4, wrong_route.id)]
})
self.env['procurement.group'].run_scheduler()
self.assertTrue(self.env['stock.move'].search([('product_id', 'in', products.ids)]))
for index in [50, 99, 150, 199]:
self.assertTrue(self.env['mail.activity'].search([
('res_id', '=', products[index].product_tmpl_id.id),
('res_model_id', '=', self.env.ref('product.model_product_template').id)
]))