diff --git a/CHANGELOG b/CHANGELOG
index 52d855b1be..5f5087b5b8 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,3 +1,6 @@
+* Add quantities relates on Template
+* Allow grouping per product columns to compute quantities
+
Version 4.6.0 - 2017-10-30
* Bug fixes (see mercurial logs for details)
* Add flat children option on location
diff --git a/location.py b/location.py
index 6556b84343..081b415301 100644
--- a/location.py
+++ b/location.py
@@ -304,62 +304,84 @@ class Location(ModelSQL, ModelView):
pool = Pool()
Product = pool.get('product.product')
Date_ = pool.get('ir.date')
+ trans_context = Transaction().context
- if (not Transaction().context.get('product')) \
- or not (isinstance(Transaction().context['product'],
- (int, long))):
- return dict([(l.id, 0) for l in locations])
+ def valid_context(name):
+ return (trans_context.get(name) is not None
+ and isinstance(trans_context[name], (int, long)))
- with Transaction().set_context(active_test=False):
- if not Product.search([
- ('id', '=', Transaction().context['product']),
- ]):
- return dict([(l.id, 0) for l in locations])
+ if not any(map(valid_context, ['product', 'product_template'])):
+ return {l.id: None for l in locations}
context = {}
if (name == 'quantity'
- and Transaction().context.get('stock_date_end') >
- Date_.today()):
+ and trans_context.get('stock_date_end') > Date_.today()):
context['stock_date_end'] = Date_.today()
if name == 'forecast_quantity':
context['forecast'] = True
- if not Transaction().context.get('stock_date_end'):
+ if not trans_context.get('stock_date_end'):
context['stock_date_end'] = datetime.date.max
- product_id = Transaction().context['product']
+ if trans_context.get('product') is not None:
+ product_ids = [trans_context['product']]
+ grouping = None
+ grouping_filter = None
+ key = trans_context['product']
+ else:
+ product_ids = None
+ grouping = ('product.template',)
+ grouping_filter = ([trans_context['product_template']],)
+ key = trans_context['product_template']
pbl = {}
for sub_locations in grouped_slice(locations):
location_ids = [l.id for l in sub_locations]
with Transaction().set_context(context):
pbl.update(Product.products_by_location(
- location_ids=location_ids, product_ids=[product_id],
+ location_ids=location_ids,
+ product_ids=product_ids,
+ grouping=grouping,
+ grouping_filter=grouping_filter,
with_childs=True))
- return dict((loc.id, pbl.get((loc.id, product_id), 0))
- for loc in locations)
+ return dict((loc.id, pbl.get((loc.id, key), 0)) for loc in locations)
@classmethod
def get_cost_value(cls, locations, name):
- Product = Pool().get('product.product')
+ pool = Pool()
+ Product = pool.get('product.product')
+ Template = pool.get('product.template')
trans_context = Transaction().context
- product_id = trans_context.get('product')
- if not product_id:
- return dict((l.id, None) for l in locations)
- cost_values, context = {}, {}
+ cost_values = {l.id: None for l in locations}
+
+ def valid_context(name):
+ return (trans_context.get(name) is not None
+ and isinstance(trans_context[name], (int, long)))
+
+ if not any(map(valid_context, ['product', 'product_template'])):
+ return cost_values
+
+ context = {}
if 'stock_date_end' in trans_context:
# Use the last cost_price of the day
context['_datetime'] = datetime.datetime.combine(
trans_context['stock_date_end'], datetime.time.max)
with Transaction().set_context(context):
- product = Product(product_id)
+
+ if trans_context.get('product') is not None:
+ product = Product(trans_context['product'])
+ cost_price = product.cost_price
+ else:
+ template = Template(trans_context['product_template'])
+ cost_price = template.cost_price
+ # The date could be before the product creation
+ # or the template may have more than one product
+ if not isinstance(cost_price, Decimal):
+ return cost_values
+
for location in locations:
- # The date could be before the product creation
- if not isinstance(product.cost_price, Decimal):
- cost_values[location.id] = None
- else:
- cost_values[location.id] = (Decimal(str(location.quantity))
- * product.cost_price)
+ cost_values[location.id] = (
+ Decimal(str(location.quantity)) * cost_price)
return cost_values
@classmethod
diff --git a/move.py b/move.py
index 58c05768ae..f1fe4ce004 100644
--- a/move.py
+++ b/move.py
@@ -980,8 +980,8 @@ class Move(Workflow, ModelSQL, ModelView):
quantities of all child locations but quantities of the storage
zone.
If with_childs, it computes also for child locations.
- grouping is a tuple of Move field names and defines how stock moves are
- grouped.
+ grouping is a tuple of Move (or Product if prefixed by 'product.')
+ field names and defines how stock moves are grouped.
grouping_filter is a tuple of values, for the Move's field at the same
position in grouping tuple, used to filter which moves are used to
compute quantities. It must be None or have the same number of
@@ -997,6 +997,7 @@ class Move(Workflow, ModelSQL, ModelView):
Date = pool.get('ir.date')
Period = pool.get('stock.period')
Move = pool.get('stock.move')
+ Product = pool.get('product.product')
move = Move.__table__()
today = Date.today()
@@ -1005,17 +1006,51 @@ class Move(Workflow, ModelSQL, ModelView):
return None
context = Transaction().context.copy()
+ use_product = False
for field in grouping:
- if field not in Move._fields:
- raise ValueError('"%s" has no field "%s"' % (Move, field))
+ if field.startswith('product.'):
+ Model = Product
+ field = field[len('product.'):]
+ use_product = True
+ else:
+ Model = Move
+ if field not in Model._fields:
+ raise ValueError('"%s" has no field "%s"' % (Model, field))
assert grouping_filter is None or len(grouping_filter) == len(grouping)
move_rule_query = Rule.query_get('stock.move')
+ def get_column(name, table, product):
+ if name.startswith('product.'):
+ column = Column(product, name[len('product.'):])
+ else:
+ column = Column(table, name)
+ return column.as_(name)
+
+ if use_product:
+ product = Product.__table__()
+ columns = ['id', 'state', 'effective_date', 'planned_date',
+ 'internal_quantity', 'from_location', 'to_location']
+ columns += list(grouping)
+ columns = [get_column(c, move, product) for c in columns]
+ move = (move
+ .join(product, condition=move.product == product.id)
+ .select(*columns))
+
PeriodCache = Period.get_cache(grouping)
period = None
if PeriodCache:
period_cache = PeriodCache.__table__()
+ if use_product:
+ product_cache = Product.__table__()
+ columns = ['internal_quantity', 'period', 'location']
+ columns += list(grouping)
+ columns = [get_column(c, period_cache, product_cache)
+ for c in columns]
+ period_cache = (period_cache
+ .join(product_cache,
+ condition=period_cache.product == product_cache.id)
+ .select(*columns))
if with_childs:
# Replace tables with union which replaces flat children locations
@@ -1333,17 +1368,24 @@ class Move(Workflow, ModelSQL, ModelView):
"""
pool = Pool()
Product = pool.get('product.product')
+ Template = pool.get('product.template')
assert query is not None, (
"Query in Move.compute_quantities() can't be None")
- assert 'product' in grouping
+ assert 'product' in grouping or 'product.template' in grouping
cursor = Transaction().connection.cursor()
cursor.execute(*query)
raw_lines = cursor.fetchall()
- product_getter = operator.itemgetter(grouping.index('product') + 1)
- res_product_ids = set()
+ if 'product' in grouping:
+ id_name = 'product'
+ Model = Product
+ else:
+ id_name = 'product.template'
+ Model = Template
+ id_getter = operator.itemgetter(grouping.index(id_name) + 1)
+ ids = set()
quantities = defaultdict(lambda: 0)
keys = set()
# We can do a quick loop without propagation if the request is for a
@@ -1357,7 +1399,7 @@ class Move(Workflow, ModelSQL, ModelView):
key = tuple(line[1:-1])
quantity = line[-1]
quantities[(location,) + key] += quantity
- res_product_ids.add(product_getter(line))
+ ids.add(id_getter(line))
keys.add(key)
# Propagate quantities on from child locations to their parents
@@ -1400,11 +1442,10 @@ class Move(Workflow, ModelSQL, ModelView):
# Round quantities
default_uom = dict((p.id, p.default_uom) for p in
- Product.browse(list(res_product_ids)))
+ Model.browse(list(ids)))
for key, quantity in quantities.iteritems():
location = key[0]
- product = product_getter(key)
- uom = default_uom[product]
+ uom = default_uom[id_getter(key)]
quantities[key] = uom.round(quantity)
return quantities
diff --git a/period.py b/period.py
index acd6957b9f..9e91b4413e 100644
--- a/period.py
+++ b/period.py
@@ -69,7 +69,7 @@ class Period(Workflow, ModelSQL, ModelView):
@staticmethod
def get_cache(grouping):
pool = Pool()
- if grouping == ('product',):
+ if all(g == 'product' or g.startswith('product.') for g in grouping):
return pool.get('stock.period.cache')
def get_rec_name(self, name):
diff --git a/product.py b/product.py
index 5be9b88666..9a8bd47ce0 100644
--- a/product.py
+++ b/product.py
@@ -133,7 +133,7 @@ class Product(StockMixin, object):
@classmethod
def products_by_location(cls, location_ids, product_ids=None,
- with_childs=False, grouping=('product',)):
+ with_childs=False, grouping=None, grouping_filter=None):
"""
Compute for each location and product the stock quantity in the default
uom of the product.
@@ -166,7 +166,12 @@ class Product(StockMixin, object):
wh_to_add[location.id] = location.storage_location.id
location_ids = list(location_ids)
- grouping_filter = (product_ids,) + tuple(None for k in grouping[1:])
+ if grouping is None or grouping == ('product',):
+ grouping = ('product',)
+ grouping_filter = (product_ids,)
+ elif product_ids:
+ grouping = ('product',) + tuple(grouping)
+ grouping_filter = (product_ids,) + tuple(grouping_filter)
query = Move.compute_quantities_query(location_ids, with_childs,
grouping=grouping, grouping_filter=grouping_filter)
if query is None:
@@ -303,23 +308,32 @@ class ProductQuantitiesByWarehouse(ModelSQL, ModelView):
pool = Pool()
Move = pool.get('stock.move')
Location = pool.get('stock.location')
- move = Move.__table__()
+ Product = pool.get('product.product')
+ move = from_ = Move.__table__()
+ context = Transaction().context
- product_id = Transaction().context.get('product')
- warehouse_id = Transaction().context.get('warehouse', -1)
+ if context.get('product_template') is not None:
+ product = Product.__table__()
+ from_ = move.join(product, condition=move.product == product.id)
+ product_clause = (
+ product.template == context['product_template'])
+ else:
+ product_clause = move.product == context.get('product', -1)
+
+ warehouse_id = context.get('warehouse', -1)
warehouse_query = Location.search([
('parent', 'child_of', [warehouse_id]),
], query=True, order=[])
date_column = Coalesce(move.effective_date, move.planned_date
).as_('date')
- return move.select(
+ return from_.select(
Max(move.id).as_('id'),
Literal(0).as_('create_uid'),
CurrentTimestamp().as_('create_date'),
Literal(None).as_('write_uid'),
Literal(None).as_('write_date'),
date_column,
- where=(move.product == product_id)
+ where=product_clause
& (move.from_location.in_(warehouse_query)
| move.to_location.in_(warehouse_query))
& (Coalesce(move.effective_date, move.planned_date) != Null),
@@ -328,9 +342,26 @@ class ProductQuantitiesByWarehouse(ModelSQL, ModelView):
@classmethod
def get_quantity(cls, lines, name):
Product = Pool().get('product.product')
+ trans_context = Transaction().context
- product_id = Transaction().context.get('product')
- warehouse_id = Transaction().context.get('warehouse')
+ def valid_context(name):
+ return (trans_context.get(name) is not None
+ and isinstance(trans_context[name], (int, long)))
+
+ if not any(map(valid_context, ['product', 'product_template'])):
+ return {l.id: None for l in lines}
+
+ if trans_context.get('product') is not None:
+ product_ids = [trans_context['product']]
+ grouping = None
+ grouping_filter = None
+ key = trans_context['product']
+ else:
+ product_ids = None
+ grouping = ('product.template',)
+ grouping_filter = ([trans_context.get('product_template')],)
+ key = trans_context['product_template']
+ warehouse_id = trans_context.get('warehouse')
dates = sorted(l.date for l in lines)
quantities = {}
@@ -343,8 +374,11 @@ class ProductQuantitiesByWarehouse(ModelSQL, ModelView):
}
with Transaction().set_context(**context):
quantities[date] = Product.products_by_location(
- [warehouse_id], [product_id],
- with_childs=True).get((warehouse_id, product_id), 0)
+ location_ids=[warehouse_id],
+ product_ids=product_ids,
+ grouping=grouping,
+ grouping_filter=grouping_filter,
+ with_childs=True).get((warehouse_id, key), 0)
try:
date_start = date + datetime.timedelta(1)
except OverflowError:
diff --git a/product.xml b/product.xml
index 1a1f4af37b..2711be5d6a 100644
--- a/product.xml
+++ b/product.xml
@@ -44,8 +44,8 @@ this repository contains the full copyright notices and license terms. -->
Locations Quantity
stock.location
-
+
product.by_location.context
@@ -59,6 +59,12 @@ this repository contains the full copyright notices and license terms. -->
product.product,-1
+
+ form_relate
+ product.template,-1
+
+
@@ -87,8 +93,8 @@ this repository contains the full copyright notices and license terms. -->
id="act_product_quantities_warehouse">
Product Quantities By Warehouse
stock.product_quantities_warehouse
-
+
stock.product_quantities_warehouse.context
@@ -119,6 +125,12 @@ this repository contains the full copyright notices and license terms. -->
product.product,-1
+
+ form_relate
+ product.template,-1
+
+
diff --git a/tests/test_stock.py b/tests/test_stock.py
index 158a2b0375..6643662885 100644
--- a/tests/test_stock.py
+++ b/tests/test_stock.py
@@ -551,6 +551,77 @@ class StockTestCase(ModuleTestCase):
"Test products_by_location on flat_childs with period closed"
self.test_products_by_location_flat_childs(period_closed=True)
+ @with_transaction()
+ def test_templates_by_location(self, period_closed=False):
+ "Test products_by_location grouped by template"
+ 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')
+ Period = pool.get('stock.period')
+ Date = pool.get('ir.date')
+
+ unit, = Uom.search([('name', '=', 'Unit')])
+ template, = Template.create([{
+ 'name': 'Template',
+ 'type': 'goods',
+ 'list_price': Decimal(0),
+ 'default_uom': unit.id,
+ }])
+ product1, product2 = Product.create([{
+ 'template': template.id,
+ }] * 2)
+
+ lost_found, = Location.search([('type', '=', 'lost_found')])
+ storage, = Location.search([('code', '=', 'STO')])
+ input_, = Location.search([('code', '=', 'IN')])
+ company = create_company()
+ with set_company(company):
+ date = Date.today() - relativedelta(days=1)
+
+ moves = Move.create([{
+ 'product': product1.id,
+ 'uom': unit.id,
+ 'quantity': 2,
+ 'from_location': lost_found.id,
+ 'to_location': storage.id,
+ 'planned_date': date,
+ 'effective_date': date,
+ 'company': company.id,
+ }, {
+ 'product': product2.id,
+ 'uom': unit.id,
+ 'quantity': 3,
+ 'from_location': input_.id,
+ 'to_location': storage.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])
+
+ templates_by_location = Product.products_by_location(
+ [storage.id],
+ grouping=('product.template',),
+ grouping_filter=([template.id],))
+
+ self.assertDictEqual(templates_by_location, {
+ (storage.id, template.id): 5,
+ })
+
+ def test_templates_by_location_period_closed(self):
+ "Test products_by_location grouped by template with period closed"
+ self.test_templates_by_location(period_closed=True)
+
@with_transaction()
def test_period(self):
'Test period'