From 2ad008f4906e43dc565c33ae673740eb116a8068 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Thu, 28 Sep 2017 23:42:22 +0200 Subject: [PATCH] Add flat children option on location issue6563 review36391002 --- CHANGELOG | 1 + doc/index.rst | 2 + location.py | 22 ++++++-- move.py | 114 +++++++++++++++++++++++++++++++++++++---- tests/test_stock.py | 109 +++++++++++++++++++++++++++++++++++++++ view/location_form.xml | 7 +-- 6 files changed, 240 insertions(+), 15 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index cf39ad236a..dfded08563 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Add flat children option on location * Raise single warning for move without origin * Allow to set manual effective date on move * Improve computation of cost price diff --git a/doc/index.rst b/doc/index.rst index 1e0c637e8f..1c95705a29 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -57,6 +57,8 @@ virtually stored. The following location types are defined: Locations are organised in tree structures, allowing to define fine grained structures. +It is possible to restrict a location to have only one level of children, this +allows to improve the performance of the stock quantity computation. Location Lead Time ------------------ diff --git a/location.py b/location.py index 77884ba28d..6556b84343 100644 --- a/location.py +++ b/location.py @@ -54,6 +54,9 @@ class Location(ModelSQL, ModelView): left = fields.Integer('Left', required=True, select=True) right = fields.Integer('Right', required=True, select=True) childs = fields.One2Many("stock.location", "parent", "Children") + flat_childs = fields.Boolean( + "Flat Children", + help="Check to restrict to one level of children.") warehouse = fields.Function(fields.Many2One('stock.location', 'Warehouse'), 'get_warehouse') input_location = fields.Many2One( @@ -126,8 +129,17 @@ class Location(ModelSQL, ModelView): "to be deactivated."), }) - parent_domain = [] - childs_domain = [] + parent_domain = [ + ['OR', + ('parent.flat_childs', '=', False), + ('parent', '=', None), + ] + ] + childs_domain = [ + If(Eval('flat_childs', False), + ('childs', '=', None), + ()), + ] childs_mapping = cls._childs_domain() for type_, allowed_parents in cls._parent_domain().iteritems(): parent_domain.append(If(Eval('type') == type_, @@ -136,7 +148,7 @@ class Location(ModelSQL, ModelView): ('type', 'in', childs_mapping[type_]), ())) cls.parent.domain = parent_domain cls.childs.domain = childs_domain - cls.childs.depends.append('type') + cls.childs.depends.extend(['flat_childs', 'type']) @classmethod def _parent_domain(cls): @@ -256,6 +268,10 @@ class Location(ModelSQL, ModelView): def default_right(): return 0 + @classmethod + def default_flat_childs(cls): + return False + @staticmethod def default_type(): return 'storage' diff --git a/move.py b/move.py index 745ef35812..86713a2a20 100644 --- a/move.py +++ b/move.py @@ -9,7 +9,7 @@ from itertools import groupby from sql import Literal, Union, Column, Null, For from sql.aggregate import Sum -from sql.conditionals import Coalesce +from sql.conditionals import Coalesce, Case from sql.operators import Concat from trytond.model import Workflow, Model, ModelView, ModelSQL, fields, Check @@ -1012,6 +1012,84 @@ class Move(Workflow, ModelSQL, ModelView): if PeriodCache: period_cache = PeriodCache.__table__() + if with_childs: + # Replace tables with union which replaces flat children locations + # by their parent location. + from_location = Location.__table__() + from_parent_location = Location.__table__() + to_location = Location.__table__() + to_parent_location = Location.__table__() + columns = ['id', 'state', 'effective_date', 'planned_date', + 'internal_quantity'] + list(grouping) + columns = [Column(move, c).as_(c) for c in columns] + + move_with_parent = (move + .join(from_location, + condition=move.from_location == from_location.id) + .join(from_parent_location, type_='LEFT', + condition=from_location.parent == from_parent_location.id) + .join(to_location, + condition=move.to_location == to_location.id) + .join(to_parent_location, type_='LEFT', + condition=to_location.parent == to_parent_location.id)) + + move = Union( + # Moves not linked to any flat location + move_with_parent.select( + move.from_location.as_('from_location'), + move.to_location.as_('to_location'), + *columns, + where=(Coalesce(from_parent_location.flat_childs, False) + != True) + & (Coalesce(to_parent_location.flat_childs, False) + != True)), + # Append moves linked from/to flat locations to their parents + move_with_parent.select( + Case( + (from_parent_location.flat_childs, + from_parent_location.id), + else_=move.from_location).as_('from_location'), + Case( + (to_parent_location.flat_childs, + to_parent_location.id), + else_=move.to_location).as_('to_location'), + *columns, + where=(from_parent_location.flat_childs == True) + | (to_parent_location.flat_childs == True)), + # Append moves linked to from/to flat locations only + move_with_parent.select( + Case( + (from_parent_location.flat_childs, + from_location.id), + else_=Null).as_('from_location'), + Case( + (to_parent_location.flat_childs, + to_location.id), + else_=Null).as_('to_location'), + *columns, + where=(from_parent_location.flat_childs == True) + | (to_parent_location.flat_childs == True)), + all_=True) + + if PeriodCache: + location = Location.__table__() + parent_location = Location.__table__() + columns = ['internal_quantity', 'period'] + list(grouping) + columns = [Column(period_cache, c).as_(c) for c in columns] + period_cache = Union( + period_cache.select( + period_cache.location.as_('location'), + *columns), + period_cache.join(location, + condition=period_cache.location == location.id + ).join(parent_location, type_='LEFT', + condition=location.parent == parent_location.id + ).select( + parent_location.id.as_('location'), + *columns, + where=parent_location.flat_childs == True), + all_=True) + if not context.get('stock_date_end'): context['stock_date_end'] = datetime.date.max @@ -1152,9 +1230,7 @@ class Move(Workflow, ModelSQL, ModelView): state_date_clause_out &= state_date_clause() if with_childs: - location_query = Location.search([ - ('parent', 'child_of', location_ids), - ], query=True, order=[]) + location_query = _location_children(location_ids, query=True) else: location_query = location_ids[:] @@ -1251,7 +1327,6 @@ class Move(Workflow, ModelSQL, ModelView): and quantity as value. """ pool = Pool() - Location = pool.get('stock.location') Product = pool.get('product.product') assert query is not None, ( @@ -1283,15 +1358,13 @@ class Move(Workflow, ModelSQL, ModelView): # Propagate quantities on from child locations to their parents if with_childs and len(location_ids) > 1: # Fetch all child locations - locations = Location.search([ - ('parent', 'child_of', location_ids), - ]) + locations = _location_children(location_ids) # Generate a set of locations without childs and a dict # giving the parent of each location. leafs = set([l.id for l in locations]) parent = {} for location in locations: - if not location.parent: + if not location.parent or location.parent.flat_childs: continue if location.parent.id in leafs: leafs.remove(location.parent.id) @@ -1330,3 +1403,26 @@ class Move(Workflow, ModelSQL, ModelView): quantities[key] = uom.round(quantity) return quantities + + +def _location_children(location_ids, query=False): + "Return children location without including flat children" + pool = Pool() + Location = pool.get('stock.location') + nested_location_ids = [] + flat_location_ids = [] + for location in Location.browse(location_ids): + if location.flat_childs: + flat_location_ids.append(location.id) + else: + nested_location_ids.append(location.id) + if nested_location_ids: + return Location.search(['OR', + ('parent', 'child_of', nested_location_ids), + ('id', 'in', flat_location_ids), + ], query=query, order=[]) + else: + if query: + return flat_location_ids + else: + return Location.browse(flat_location_ids) diff --git a/tests/test_stock.py b/tests/test_stock.py index bc336e50bd..158a2b0375 100644 --- a/tests/test_stock.py +++ b/tests/test_stock.py @@ -442,6 +442,115 @@ class StockTestCase(ModuleTestCase): ]) self.assertListEqual([product], found_products) + @with_transaction() + def test_products_by_location_flat_childs(self, period_closed=False): + "Test products_by_location on flat_childs" + pool = Pool() + Uom = pool.get('product.uom') + Template = pool.get('product.template') + Product = pool.get('product.product') + Location = pool.get('stock.location') + Move = pool.get('stock.move') + Date = pool.get('ir.date') + Period = pool.get('stock.period') + + unit, = Uom.search([('name', '=', 'Unit')]) + template, = Template.create([{ + 'name': "Product", + 'type': 'goods', + 'list_price': Decimal(0), + 'default_uom': unit.id, + }]) + product, = Product.create([{ + 'template': template.id, + }]) + + lost_found, = Location.search([('type', '=', 'lost_found')]) + storage, = Location.search([('code', '=', 'STO')]) + storage.flat_childs = True + storage.save() + storage1, = Location.create([{ + 'name': 'Storage 1', + 'type': 'storage', + 'parent': storage.id, + }]) + storage2, = Location.create([{ + 'name': 'Storage 2', + 'type': 'storage', + 'parent': storage.id, + }]) + + company = create_company() + with set_company(company): + date = Date.today() - relativedelta(days=1) + + moves = Move.create([{ + 'product': product.id, + 'uom': unit.id, + 'quantity': 1, + 'from_location': lost_found.id, + 'to_location': storage.id, + 'planned_date': date, + 'effective_date': date, + 'company': company.id, + }, { + 'product': product.id, + 'uom': unit.id, + 'quantity': 1, + 'from_location': lost_found.id, + 'to_location': storage1.id, + 'planned_date': date, + 'effective_date': date, + 'company': company.id, + }, { + 'product': product.id, + 'uom': unit.id, + 'quantity': 1, + 'from_location': storage1.id, + 'to_location': storage2.id, + 'planned_date': date, + 'effective_date': date, + 'company': company.id, + }]) + Move.do(moves) + + if period_closed: + period, = Period.create([{ + 'date': date, + 'company': company.id, + }]) + Period.close([period]) + + # Test flat location + products_by_location = Product.products_by_location( + [storage.id], [product.id], with_childs=True) + self.assertEqual( + products_by_location[(storage.id, product.id)], 2) + + # Test mixed flat and nested location + products_by_location = Product.products_by_location( + [storage.parent.id, storage.id, storage1.id, storage2.id], + [product.id], + with_childs=True) + self.assertEqual( + products_by_location[(storage.parent.id, product.id)], 2) + self.assertEqual( + products_by_location[(storage.id, product.id)], 2) + self.assertEqual( + products_by_location[(storage1.id, product.id)], 0) + self.assertEqual( + products_by_location[(storage2.id, product.id)], 1) + + # Test non flat + products_by_location = Product.products_by_location( + [lost_found.id], [product.id], with_childs=True) + self.assertEqual( + products_by_location[(lost_found.id, product.id)], -2) + + def test_products_by_location_flat_childs_period_closed(self): + "Test products_by_location on flat_childs with period closed" + self.test_products_by_location_flat_childs(period_closed=True) + @with_transaction() def test_period(self): 'Test period' diff --git a/view/location_form.xml b/view/location_form.xml index 7e0e3f247f..5134360cfb 100644 --- a/view/location_form.xml +++ b/view/location_form.xml @@ -6,13 +6,14 @@ this repository contains the full copyright notices and license terms. -->