Commit cf53db45 authored by Aurel's avatar Aurel

Implement cache of getInventory results

Create a sql cache for getInventory call
Remove full-inventory optimisation as it now useless
Implement full-inventory feature directly into stock
Make erp5_pdm depends on this optimisation
Install optimisation for all unit tests
parent 3f484980
<catalog_method>
<item key="sql_catalog_object_list" type="int">
<value>1</value>
</item>
</catalog_method>
<?xml version="1.0"?>
<ZopeData>
<record id="1" aka="AAAAAAAAAAE=">
<pickle>
<global name="SQL" module="Products.ZSQLMethods.SQL"/>
</pickle>
<pickle>
<dictionary>
<item>
<key> <string>arguments_src</string> </key>
<value> <string>isInventory\r\n
uid\r\n
getDestinationUid\r\n
getDestinationSectionUid\r\n
getDestinationPaymentUid\r\n
getStartDate\r\n
isFullInventory\r\n
getSimulationState</string> </value>
</item>
<item>
<key> <string>connection_id</string> </key>
<value> <string>erp5_sql_connection</string> </value>
</item>
<item>
<key> <string>id</string> </key>
<value> <string>z_catalog_inventory_list</string> </value>
</item>
<item>
<key> <string>src</string> </key>
<value> <string encoding="cdata"><![CDATA[
<dtml-let row_list="[]">\n
\n
<dtml-in prefix="loop" expr="_.range(_.len(uid))">\n
<dtml-if expr="isInventory[loop_item]">\n
<dtml-if expr="getStartDate[loop_item] is not None">\n
<dtml-call expr="row_list.append((\n
uid[loop_item],\n
getDestinationUid[loop_item],\n
getDestinationSectionUid[loop_item],\n
getDestinationPaymentUid[loop_item],\n
getStartDate[loop_item],\n
isFullInventory[loop_item],\n
getSimulationState[loop_item]))">\n
</dtml-if>\n
</dtml-if>\n
</dtml-in>\n
\n
<dtml-if expr="len(row_list)">\n
REPLACE INTO\n
inventory\n
(`uid`, `node_uid`, `section_uid`, `payment_uid`, `date`, `is_full_inventory`, `simulation_state`)\n
VALUES\n
<dtml-in prefix="row" expr="row_list">\n
(<dtml-sqlvar expr="row_item[0]" type="int">,\n
<dtml-sqlvar expr="row_item[1]" type="int" optional>,\n
<dtml-sqlvar expr="row_item[2]" type="int" optional>,\n
<dtml-sqlvar expr="row_item[3]" type="int" optional>,\n
<dtml-sqlvar expr="row_item[4]" type="datetime">,\n
<dtml-sqlvar expr="row_item[5] or 0" type="int">,\n
<dtml-sqlvar expr="row_item[6]" type="string">\n
)<dtml-if sequence-end><dtml-else>,</dtml-if>\n
</dtml-in>\n
</dtml-if>\n
</dtml-let>
]]></string> </value>
</item>
<item>
<key> <string>title</string> </key>
<value> <string></string> </value>
</item>
</dictionary>
</pickle>
</record>
</ZopeData>
erp5_base
\ No newline at end of file
erp5_base
erp5_stock_cache
571
\ No newline at end of file
573
\ No newline at end of file
erp5_base
\ No newline at end of file
erp5_base
......@@ -6,7 +6,7 @@
<value>1</value>
</item>
<item key="_filter_expression_archive" type="str">
<value>python: context.providesIMovement() and context.isInventoryMovement()</value>
<value>python: context.providesIMovement() and not context.isInventoryMovement()</value>
</item>
<item key="_filter_expression_cache_key_archive" type="tuple">
<value>portal_type</value>
......
<?xml version="1.0"?>
<ZopeData>
<record id="1" aka="AAAAAAAAAAE=">
<pickle>
<global name="PythonScript" module="Products.PythonScripts.PythonScript"/>
</pickle>
<pickle>
<dictionary>
<item>
<key> <string>Script_magic</string> </key>
<value> <int>3</int> </value>
</item>
<item>
<key> <string>_bind_names</string> </key>
<value>
<object>
<klass>
<global name="NameAssignments" module="Shared.DC.Scripts.Bindings"/>
</klass>
<tuple/>
<state>
<dictionary>
<item>
<key> <string>_asgns</string> </key>
<value>
<dictionary>
<item>
<key> <string>name_container</string> </key>
<value> <string>container</string> </value>
</item>
<item>
<key> <string>name_context</string> </key>
<value> <string>context</string> </value>
</item>
<item>
<key> <string>name_m_self</string> </key>
<value> <string>script</string> </value>
</item>
<item>
<key> <string>name_subpath</string> </key>
<value> <string>traverse_subpath</string> </value>
</item>
</dictionary>
</value>
</item>
</dictionary>
</state>
</object>
</value>
</item>
<item>
<key> <string>_body</string> </key>
<value> <string>from Products.ERP5Type.Errors import ProgrammingError\n
\n
min_date = None\n
for loop_item in xrange(len(uid)):\n
if not isInventoryMovement[loop_item] and isMovement[loop_item] and getResourceUid[loop_item]:\n
if getDestinationUid[loop_item] and getStopDate[loop_item]:\n
if min_date:\n
min_date = min(min_date, getStopDate[loop_item])\n
else:\n
min_date = getStopDate[loop_item]\n
if getSourceUid[loop_item] and getStartDate[loop_item]:\n
if min_date:\n
min_date = min(min_date, getStartDate[loop_item])\n
else:\n
min_date = getStartDate[loop_item]\n
if min_date:\n
try:\n
context.SimulationTool_zTrimInventoryCacheFromDateOnCatalog(date=min_date)\n
except ProgrammingError:\n
# Create table if it does not exits\n
# Then no need to flush an empty table\n
context.SimulationTool_zCreateInventoryCache()\n
</string> </value>
</item>
<item>
<key> <string>_params</string> </key>
<value> <string>uid, isMovement, isInventoryMovement, getResourceUid, getDestinationUid, getStopDate, getSourceUid, getStartDate</string> </value>
</item>
<item>
<key> <string>id</string> </key>
<value> <string>SQLCatalog_trimInventoryCacheOnCatalog</string> </value>
</item>
</dictionary>
</pickle>
</record>
</ZopeData>
<?xml version="1.0"?>
<ZopeData>
<record id="1" aka="AAAAAAAAAAE=">
<pickle>
<global name="PythonScript" module="Products.PythonScripts.PythonScript"/>
</pickle>
<pickle>
<dictionary>
<item>
<key> <string>Script_magic</string> </key>
<value> <int>3</int> </value>
</item>
<item>
<key> <string>_bind_names</string> </key>
<value>
<object>
<klass>
<global name="NameAssignments" module="Shared.DC.Scripts.Bindings"/>
</klass>
<tuple/>
<state>
<dictionary>
<item>
<key> <string>_asgns</string> </key>
<value>
<dictionary>
<item>
<key> <string>name_container</string> </key>
<value> <string>container</string> </value>
</item>
<item>
<key> <string>name_context</string> </key>
<value> <string>context</string> </value>
</item>
<item>
<key> <string>name_m_self</string> </key>
<value> <string>script</string> </value>
</item>
<item>
<key> <string>name_subpath</string> </key>
<value> <string>traverse_subpath</string> </value>
</item>
</dictionary>
</value>
</item>
</dictionary>
</state>
</object>
</value>
</item>
<item>
<key> <string>_body</string> </key>
<value> <string>from Products.ERP5Type.Errors import ProgrammingError\n
\n
try:\n
context.SimulationTool_zTrimInventoryCacheFromDateOnUncatalog(uid=uid)\n
except ProgrammingError:\n
# Create table if it does not exits\n
# Then no need to flush an empty table\n
context.SimulationTool_zCreateInventoryCache()\n
</string> </value>
</item>
<item>
<key> <string>_params</string> </key>
<value> <string>uid</string> </value>
</item>
<item>
<key> <string>id</string> </key>
<value> <string>SQLCatalog_trimInventoryCacheOnUncatalog</string> </value>
</item>
</dictionary>
</pickle>
</record>
</ZopeData>
<?xml version="1.0"?>
<ZopeData>
<record id="1" aka="AAAAAAAAAAE=">
<pickle>
<global name="Folder" module="OFS.Folder"/>
</pickle>
<pickle>
<dictionary>
<item>
<key> <string>_objects</string> </key>
<value>
<tuple/>
</value>
</item>
<item>
<key> <string>id</string> </key>
<value> <string>erp5_stock_cache</string> </value>
</item>
<item>
<key> <string>title</string> </key>
<value> <string></string> </value>
</item>
</dictionary>
</pickle>
</record>
</ZopeData>
......@@ -6,14 +6,32 @@
</pickle>
<pickle>
<dictionary>
<item>
<key> <string>allow_simple_one_argument_traversal</string> </key>
<value>
<none/>
</value>
</item>
<item>
<key> <string>arguments_src</string> </key>
<value> <string>column_value_list_list:list\r\n
column_id_list:list\r\n
group_by_expression\r\n
date\r\n
simulation_state=delivered\r\n
where_expression</string> </value>
<value> <string>query\r\n
date</string> </value>
</item>
<item>
<key> <string>cache_time_</string> </key>
<value> <int>0</int> </value>
</item>
<item>
<key> <string>class_file_</string> </key>
<value> <string></string> </value>
</item>
<item>
<key> <string>class_name_</string> </key>
<value> <string></string> </value>
</item>
<item>
<key> <string>connection_hook</string> </key>
<value> <string></string> </value>
</item>
<item>
<key> <string>connection_id</string> </key>
......@@ -21,39 +39,31 @@ where_expression</string> </value>
</item>
<item>
<key> <string>id</string> </key>
<value> <string>Resource_zGetFullInventoryDate</string> </value>
<value> <string>Resource_zGetInventoryCacheResult</string> </value>
</item>
<item>
<key> <string>max_cache_</string> </key>
<value> <int>100</int> </value>
</item>
<item>
<key> <string>max_rows_</string> </key>
<value> <int>1</int> </value>
</item>
<item>
<key> <string>src</string> </key>
<value> <string encoding="cdata"><![CDATA[
SELECT\n
MAX(date) as date,\n
<dtml-var group_by_expression>\n
FROM\n
inventory\n
WHERE\n
is_full_inventory = TRUE\n
<dtml-if simulation_state>\n
AND simulation_state = <dtml-sqlvar simulation_state type="string">\n
</dtml-if>\n
<dtml-in prefix="loop" expr="_.range(_.len(column_id_list))">\n
AND <dtml-var expr="column_id_list[loop_item]"> IN (\n
<dtml-in expr="column_value_list_list[loop_item]">\n
<dtml-sqlvar sequence-item type="int">\n
<dtml-if sequence-end><dtml-else>,</dtml-if>\n
</dtml-in>\n
)\n
</dtml-in>\n
<dtml-if date>\n
AND date <= <dtml-sqlvar date type="string">\n
</dtml-if>\n
<dtml-if where_expression>\n
AND <dtml-var where_expression>\n
</dtml-if>\n
GROUP BY\n
<dtml-var group_by_expression>\n
SELECT \n
date, \n
result \n
FROM \n
inventory_cache \n
WHERE \n
inventory_cache.query=<dtml-sqlvar query type="string"> \n
AND \n
inventory_cache.date <= <dtml-sqlvar date type="datetime"> \n
ORDER BY \n
date DESC
]]></string> </value>
</item>
......
<?xml version="1.0"?>
<ZopeData>
<record id="1" aka="AAAAAAAAAAE=">
<pickle>
<global name="SQL" module="Products.ZSQLMethods.SQL"/>
</pickle>
<pickle>
<dictionary>
<item>
<key> <string>arguments_src</string> </key>
<value> <string>query\r\n
date\r\n
result</string> </value>
</item>
<item>
<key> <string>connection_id</string> </key>
<value> <string>erp5_sql_connection</string> </value>
</item>
<item>
<key> <string>id</string> </key>
<value> <string>Resource_zInsertInventoryCacheResult</string> </value>
</item>
<item>
<key> <string>src</string> </key>
<value> <string encoding="cdata"><![CDATA[
Insert into inventory_cache(`query`, `date`, `result`) values (<dtml-sqlvar query type="string">, <dtml-sqlvar date type="datetime">, <dtml-sqlvar result type="string">)
]]></string> </value>
</item>
<item>
<key> <string>title</string> </key>
<value> <string></string> </value>
</item>
</dictionary>
</pickle>
</record>
</ZopeData>
<?xml version="1.0"?>
<ZopeData>
<record id="1" aka="AAAAAAAAAAE=">
<pickle>
<global name="PythonScript" module="Products.PythonScripts.PythonScript"/>
</pickle>
<pickle>
<dictionary>
<item>
<key> <string>Script_magic</string> </key>
<value> <int>3</int> </value>
</item>
<item>
<key> <string>_bind_names</string> </key>
<value>
<object>
<klass>
<global name="NameAssignments" module="Shared.DC.Scripts.Bindings"/>
</klass>
<tuple/>
<state>
<dictionary>
<item>
<key> <string>_asgns</string> </key>
<value>
<dictionary>
<item>
<key> <string>name_container</string> </key>
<value> <string>container</string> </value>
</item>
<item>
<key> <string>name_context</string> </key>
<value> <string>context</string> </value>
</item>
<item>
<key> <string>name_m_self</string> </key>
<value> <string>script</string> </value>
</item>
<item>
<key> <string>name_subpath</string> </key>
<value> <string>traverse_subpath</string> </value>
</item>
</dictionary>
</value>
</item>
</dictionary>
</state>
</object>
</value>
</item>
<item>
<key> <string>_body</string> </key>
<value> <string># XXX To be implemented\n
</string> </value>
</item>
<item>
<key> <string>_params</string> </key>
<value> <string></string> </value>
</item>
<item>
<key> <string>id</string> </key>
<value> <string>SimulationTool_flushInventoryCache</string> </value>
</item>
</dictionary>
</pickle>
</record>
</ZopeData>
<?xml version="1.0"?>
<ZopeData>
<record id="1" aka="AAAAAAAAAAE=">
<pickle>
<global name="PythonScript" module="Products.PythonScripts.PythonScript"/>
</pickle>
<pickle>
<dictionary>
<item>
<key> <string>Script_magic</string> </key>
<value> <int>3</int> </value>
</item>
<item>
<key> <string>_bind_names</string> </key>
<value>
<object>
<klass>
<global name="NameAssignments" module="Shared.DC.Scripts.Bindings"/>
</klass>
<tuple/>
<state>
<dictionary>
<item>
<key> <string>_asgns</string> </key>
<value>
<dictionary>
<item>
<key> <string>name_container</string> </key>
<value> <string>container</string> </value>
</item>
<item>
<key> <string>name_context</string> </key>
<value> <string>context</string> </value>
</item>
<item>
<key> <string>name_m_self</string> </key>
<value> <string>script</string> </value>
</item>
<item>
<key> <string>name_subpath</string> </key>
<value> <string>traverse_subpath</string> </value>
</item>
</dictionary>
</value>
</item>
</dictionary>
</state>
</object>
</value>
</item>
<item>
<key> <string>_body</string> </key>
<value> <string>"""\n
Returns a duration, in days, for stock cache management.\n
If data in stock cache is older than lag compared to query\'s date\n
(at_date or to_date), then it becomes a "soft miss": use found value,\n
but add a new entry to cache at query\'s date minus half the lag.\n
So this value should be:\n
- Small enough that few enough rows need to be table-scanned for\n
verage queries (probably queries against current date).\n
- Large enough that few enough documents get modified past that date,\n
therwise cache entries would be removed from cache all the time.\n
"""\n
\n
return 60\n
</string> </value>
</item>
<item>
<key> <string>_params</string> </key>
<value> <string></string> </value>
</item>
<item>
<key> <string>id</string> </key>
<value> <string>SimulationTool_getInventoryCacheLag</string> </value>
</item>
</dictionary>
</pickle>
</record>
</ZopeData>
......@@ -16,11 +16,17 @@
</item>
<item>
<key> <string>id</string> </key>
<value> <string>z0_drop_inventory</string> </value>
<value> <string>SimulationTool_zCreateInventoryCache</string> </value>
</item>
<item>
<key> <string>src</string> </key>
<value> <string>DROP TABLE IF EXISTS inventory</string> </value>
<value> <string>Create table `inventory_cache` (\n
`query` BINARY(16) NOT NULL,\n
`date` datetime NOT NULL,\n
`result` LONGBLOB NOT NULL,\n
PRIMARY KEY (`query`, `date`),\n
KEY (`date`)\n
) Engine=InnoDB</string> </value>
</item>
<item>
<key> <string>title</string> </key>
......
......@@ -6,6 +6,12 @@
</pickle>
<pickle>
<dictionary>
<item>
<key> <string>_col</string> </key>
<value>
<tuple/>
</value>
</item>
<item>
<key> <string>arguments_src</string> </key>
<value> <string></string> </value>
......@@ -16,11 +22,11 @@
</item>
<item>
<key> <string>id</string> </key>
<value> <string>z0_drop_inventory_stock</string> </value>
<value> <string>SimulationTool_zDropInventoryCache</string> </value>
</item>
<item>
<key> <string>src</string> </key>
<value> <string>DROP TABLE IF EXISTS inventory_stock</string> </value>
<value> <string>DROP TABLE IF EXISTS inventory_cache</string> </value>
</item>
<item>
<key> <string>title</string> </key>
......
......@@ -8,7 +8,7 @@
<dictionary>
<item>
<key> <string>arguments_src</string> </key>
<value> <string>uid</string> </value>
<value> <string>date</string> </value>
</item>
<item>
<key> <string>connection_id</string> </key>
......@@ -16,13 +16,16 @@
</item>
<item>
<key> <string>id</string> </key>
<value> <string>z0_uncatalog_inventory_stock</string> </value>
<value> <string>SimulationTool_zTrimInventoryCacheFromDateOnCatalog</string> </value>
</item>
<item>
<key> <string>src</string> </key>
<value> <string encoding="cdata"><![CDATA[
DELETE FROM inventory_stock WHERE <dtml-sqltest uid op=eq type=int>
DELETE FROM \n
inventory_cache \n
WHERE \n
date > <dtml-sqlvar expr="date" type="datetime">
]]></string> </value>
</item>
......
......@@ -16,13 +16,13 @@
</item>
<item>
<key> <string>id</string> </key>
<value> <string>z0_uncatalog_inventory</string> </value>
<value> <string>SimulationTool_zTrimInventoryCacheFromDateOnUncatalog</string> </value>
</item>
<item>
<key> <string>src</string> </key>
<value> <string encoding="cdata"><![CDATA[
DELETE FROM inventory WHERE <dtml-sqltest uid op=eq type=int>
DELETE FROM inventory_cache WHERE date > (SELECT min(date) from stock where <dtml-sqltest uid op=eq type=int>)
]]></string> </value>
</item>
......
2013-02-05 Aurel
Initial version
\ No newline at end of file
Copyright (c) 2001-2013 Nexedi SA
\ No newline at end of file
erp5_core
\ No newline at end of file
This bt5 contains code to activate the stock caching optimisation
\ No newline at end of file
GPL
\ No newline at end of file
aurel
\ No newline at end of file
4
\ No newline at end of file
erp5_mysql_innodb/SQLCatalog_trimInventoryCacheOnCatalog
erp5_mysql_innodb/SQLCatalog_trimInventoryCacheOnUncatalog
\ No newline at end of file
erp5_stock_cache
\ No newline at end of file
erp5_stock_cache
\ No newline at end of file
1.0
\ No newline at end of file
......@@ -74,7 +74,7 @@ class Inventory(Delivery):
def appendToCategoryList(self, category_list, value, base_category):
category_list.append("%s/%s" %(base_category, value))
def splitAndExtendToCategoryList(self, category_list, value, *args, **kw):
if value is not None:
value_list = value.split('\n')
......@@ -101,16 +101,16 @@ class Inventory(Delivery):
# with not all properties defined and thus making
# request with no condition in mysql
object_list = [self]
immediate_reindex_archive = sql_catalog_id is not None
immediate_reindex_archive = sql_catalog_id is not None
self.portal_catalog.catalogObjectList(object_list,
sql_catalog_id = sql_catalog_id,
disable_archive=disable_archive,
immediate_reindex_archive=immediate_reindex_archive)
immediate_reindex_archive=immediate_reindex_archive)
return
connection_id = None
if sql_catalog_id is not None:
# try to get connection used in the catalog
# try to get connection used in the catalog
catalog = self.portal_catalog[sql_catalog_id]
for method in catalog.objectValues():
if method.meta_type == "Z SQL Method":
......@@ -139,10 +139,10 @@ class Inventory(Delivery):
},
)
method = self._getTypeBasedMethod('getDefaultInventoryCalculationList')
method = self._getTypeBasedMethod('getDefaultInventoryCalculationList')
if method is not None:
default_inventory_calculation_list = method()
if temp_constructor is None:
from Products.ERP5Type.Document import newTempMovement
......@@ -165,13 +165,11 @@ class Inventory(Delivery):
current_inventory_dict = {}
current_inventory_key_id_list = [x["key"] for x in inventory_calculation_dict['first_level']]
for line in current_inventory_list:
current_inventory_key = [line[x] for x in current_inventory_key_id_list]
for x in xrange(len(current_inventory_key)):
if current_inventory_key[x] is None:
current_inventory_key[x] = ""
current_inventory_key = tuple(current_inventory_key)
current_inventory_key = tuple(current_inventory_key)
if inventory_calculation_dict.has_key("second_level"):
# two level of variation
......@@ -189,10 +187,13 @@ class Inventory(Delivery):
current_inventory_dict[current_inventory_key] = line['total_quantity']
# Browse all movements on inventory and create diff line when necessary
not_used_inventory_dict = {}
if self.isFullInventory():
not_used_inventory_dict = current_inventory_dict
else:
not_used_inventory_dict = {}
inventory_id = self.getId()
list_method = inventory_calculation_dict['list_method']
method = getattr(self, list_method)
list_method = inventory_calculation_dict['list_method']
method = getattr(self, list_method)
for movement in method():
if movement.getResourceValue() is not None and \
movement.getInventoriatedQuantity() not in (None, ''):
......@@ -219,26 +220,26 @@ class Inventory(Delivery):
second_key_list = tuple(second_key_list)
if inventory_value.has_key(second_key_list):
total_quantity = inventory_value.pop(second_key_list)
# Put remaining subvariation in a dict to know which one
# Put remaining subvariation in a dict to know which one
# to removed at end
not_used_inventory_dict[tuple(key_list)] = inventory_value
diff_quantity = movement_quantity - total_quantity
else:
# Inventory for new resource/variation/sub_variation
diff_quantity = movement_quantity
# Put remaining subvariation in a dict to know which one
# Put remaining subvariation in a dict to know which one
# to removed at end
not_used_inventory_dict[tuple(key_list)] = inventory_value
else:
# we got the quantity from first level key
diff_quantity = movement_quantity - inventory_value
# Create tmp movement
kwd = {'uid': movement.getUid(),
'start_date': stop_date}
temp_delivery_line = temp_constructor(self,
inventory_id)
# set category on it only if quantity not null
# thus line with same uid will be deleted but we
# don't insert line with null quantity as we test
......@@ -246,7 +247,7 @@ class Inventory(Delivery):
# before insert but not before delete
if diff_quantity != 0:
kwd['quantity'] = diff_quantity
category_list = self.getCategoryList()
category_list = self.getCategoryList()
setter_list = [x['setter'] for x in inventory_calculation_dict['first_level']]
if inventory_calculation_dict.has_key("second_level"):
......@@ -267,13 +268,14 @@ class Inventory(Delivery):
temp_delivery_line.edit(**kwd)
stock_append(temp_delivery_line)
# Now create line to remove some subvariation text not present
# Now create line to remove some subvariation text not present
# in new inventory
if len(not_used_inventory_dict):
inventory_uid = self.getUid()
for first_level_key in not_used_inventory_dict.keys():
inventory_value = \
not_used_inventory_dict[tuple(first_level_key)]
# XXX-Aurel : this code does not work with only one level of variation
for second_level_key in inventory_value.keys():
diff_quantity = - inventory_value[tuple(second_level_key)]
......@@ -284,7 +286,7 @@ class Inventory(Delivery):
temp_delivery_line = temp_constructor(self,
inventory_id)
kwd['quantity'] = diff_quantity
category_list = self.getCategoryList()
category_list = self.getCategoryList()
setter_list = [x['setter'] for x in inventory_calculation_dict['first_level']]
if inventory_calculation_dict.has_key("second_level"):
......@@ -307,7 +309,7 @@ class Inventory(Delivery):
# Reindex objects
object_list = [self]
immediate_reindex_archive = sql_catalog_id is not None
immediate_reindex_archive = sql_catalog_id is not None
self.portal_catalog.catalogObjectList(object_list,
sql_catalog_id = sql_catalog_id,
disable_archive=disable_archive,
......
......@@ -36,7 +36,7 @@ from Products.ERP5Type.Tool.BaseTool import BaseTool
from Products.ERP5 import _dtmldir
from zLOG import LOG, PROBLEM
from zLOG import LOG, PROBLEM, WARNING, INFO
from Products.ERP5.Capacity.GLPK import solve
from numpy import zeros, resize
......@@ -50,8 +50,19 @@ from Products.ZSQLCatalog.SQLCatalog import Query, ComplexQuery
from Shared.DC.ZRDB.Results import Results
from Products.ERP5Type.Utils import mergeZRDBResults
from App.Extensions import getBrain
from MySQLdb import ProgrammingError
from hashlib import md5
from warnings import warn
from cPickle import loads, dumps
from copy import deepcopy
from sys import exc_info
MYSQL_MIN_DATETIME_RESOLUTION = 1/86400.
class StockOptimisationError(Exception):
pass
class SimulationTool(BaseTool):
"""
......@@ -1278,318 +1289,338 @@ class SimulationTool(BaseTool):
sql_source_list = []
# If no group at all, give a default sort group by
kw.update(self._getDefaultGroupByParameters(**kw))
base_inventory_kw = {
'stock_table_id': default_stock_table,
'src__': src__,
'ignore_variation': ignore_variation,
'standardise': standardise,
'omit_simulation': omit_simulation,
'only_accountable': only_accountable,
'selection_domain': selection_domain,
'selection_report': selection_report,
'precision': precision,
'inventory_list': inventory_list,
'connection_id': connection_id,
'statistic': statistic,
'convert_quantity_result': convert_quantity_result,
'quantity_unit_uid': quantity_unit_uid,
}
# Get cached data
if getattr(self, "Resource_zGetInventoryCacheResult", None) is not None and \
optimisation__ and 'from_date' not in kw and \
(('at_date' in kw) ^ ('to_date' in kw)) and \
'transformed_resource' not in kw:
# Here is the different kind of date
# from_date : >=
# to_date : <
# at_date : <=
# As we just have from_date, it means that we must use
# the to_date for the cache in order to avoid double computation
# of the same line
at_date = kw.pop("at_date", None)
if at_date is None:
to_date = kw.pop("to_date")
else:
# add one second so that we can use to_date
to_date = at_date + MYSQL_MIN_DATETIME_RESOLUTION
try:
cached_result, cached_date = self._getCachedInventoryList(
to_date=to_date,
sql_kw=kw,
**base_inventory_kw)
except StockOptimisationError:
cached_result = []
kw['to_date'] = to_date
else:
if src__:
sql_source_list.extend(cached_result)
# Now must generate query for date diff
kw['to_date'] = to_date
kw['from_date'] = cached_date
else:
cached_result = []
sql_kw, new_kw = self._generateKeywordDict(**kw)
# Copy kw content as _generateSQLKeywordDictFromKeywordDict
# remove some values from it
try:
new_kw_copy = deepcopy(new_kw)
except TypeError:
# new_kw contains wrong parameters
# as optimisation has already been disable we
# do not care about the deepcopy
new_kw_copy = new_kw
stock_sql_kw = self._generateSQLKeywordDictFromKeywordDict(
table=default_stock_table, sql_kw=sql_kw, new_kw=new_kw)
Resource_zGetFullInventoryDate = \
getattr(self, 'Resource_zGetFullInventoryDate', None)
EQUAL_DATE_TABLE_ID = 'inventory_stock'
GREATER_THAN_DATE_TABLE_ID = 'stock'
buildSQLQuery = self.getPortalObject().portal_catalog.buildSQLQuery
optimisation_success = optimisation__ and ('from_date' not in kw) and \
Resource_zGetFullInventoryDate is not None and \
(GREATER_THAN_DATE_TABLE_ID == default_stock_table)
# Generate first query parameter dict
if optimisation_success:
def getFirstQueryParameterDict(query_generator_kw):
optimisation_success = True
AVAILABLE_CRITERIONS_IN_INVENTORY_TABLE = ['node_uid',
'section_uid',
'payment_uid']
# Only column group by are supported in full inventories
group_by_list = query_generator_kw.get('column_group_by', [])
column_value_dict = query_generator_kw.get('column_value_dict', {})
new_group_by_list = []
new_column_value_dict = {}
for criterion_id in AVAILABLE_CRITERIONS_IN_INVENTORY_TABLE:
criterion_value_list = column_value_dict.get(criterion_id, [])
if not isinstance(criterion_value_list, (list, tuple)):
criterion_value_list = [criterion_value_list]
if len(criterion_value_list) > 0:
if len(criterion_value_list) > 1:
# Impossible to optimise if there is more than one possible
# value per criterion.
optimisation_success = False
break
new_column_value_dict[criterion_id] = criterion_value_list
new_group_by_list.append(criterion_id)
elif criterion_id in group_by_list:
new_group_by_list.append(criterion_id)
group_by_expression = ', '.join(new_group_by_list)
column_id_list = new_column_value_dict.keys()
column_value_list_list = new_column_value_dict.values()
date_value_list = column_value_dict.get('date', {}).get('query', [])
where_expression = None
if len(date_value_list) == 1:
date = date_value_list[0]
# build a query for date to take range into account
date_query_result = buildSQLQuery(**{
'inventory.date': {
'query': date,
'range': column_value_dict.get('date', {}).get('range', [])
},
'query_table': None,
})
if date_query_result['where_expression'] not in ('',None):
where_expression = date_query_result['where_expression']
elif len(date_value_list) > 1:
# When more than one date is provided, we must not optimise.
# Also, as we should never end up here (the only currently known
# case where there are 2 dates is when a from_date is provided
# along with either an at_date or a to_date, and we disable
# optimisation when from_date is given), emit a log.
# This can happen if there are more date parameters than mentioned
# above.
LOG('SimulationTool', PROBLEM, 'There is more than one date condition'
' so optimisation got disabled. The result of this call will be'
' correct but it requires investigation as some cases might'
' have gone unnoticed and produced wrong results.')
optimisation_success = False
return {'group_by_expression': group_by_expression,
'column_id_list': column_id_list,
'column_value_list_list': column_value_list_list,
'where_expression' : where_expression,}, optimisation_success
first_query_param_dict, optimisation_success = getFirstQueryParameterDict(new_kw)
if optimisation_success:
if len(first_query_param_dict['column_id_list']):
inventory_date_line_list = self.Resource_zGetFullInventoryDate(
**first_query_param_dict)
if src__:
sql_source_list.append(
self.Resource_zGetFullInventoryDate(src__=src__,
**first_query_param_dict))
# Check that all expected uids have been found, otherwise a full
# inventory of a node/section/payment might be missing.
if len(inventory_date_line_list) >= max([len(x) for x in \
first_query_param_dict['column_value_list_list']]):
# Generate a where expression which filters on dates retrieved
# in the first query to be used in the second query.
# Also, generate a where expression to use in the third query,
# since it is based on the same data.
# XXX: uggly duplicated query generation code
# XXX: duplicates SQL variable formatting present in
# ERP5Type/patches/sqlvar.py about datetime SQL columns.
# Note: This code can generate queries like:
# date = 2000/01/01 and date >= 2001/01/01
# When latest full inventory is at 2000/01/01 and given
# from_date is 2001/01/01.
# It is not a serious problem since MySQL detects incompatible
# conditions and immediately returns (with 0 rows).
# get search key definitions from portal_catalog
ctool = getToolByName(self, 'portal_catalog')
portal_catalog = ctool.getSQLCatalog()
keyword_search_keys = list(portal_catalog.sql_catalog_keyword_search_keys)
datetime_search_keys = list(portal_catalog.sql_catalog_datetime_search_keys)
full_text_search_keys = list(portal_catalog.sql_catalog_full_text_search_keys)
search_key_mapping = dict(key_alias_dict = None,
keyword_search_keys = keyword_search_keys,
datetime_search_keys = datetime_search_keys,
full_text_search_keys = full_text_search_keys)
equal_date_query_list = []
greater_than_date_query_list = []
for inventory_date_line_dict in \
inventory_date_line_list.dictionaries():
date = inventory_date_line_dict['date']
non_date_value_dict = dict([(k, v) for k, v \
in inventory_date_line_dict.iteritems() if k != 'date'])
equal_date_query_list.append(
ComplexQuery(
ComplexQuery(operator='AND',
*[Query(**{'%s.%s' % (EQUAL_DATE_TABLE_ID, k): v}) \
for k, v in non_date_value_dict.iteritems()]),
Query(**{'%s.date' % (EQUAL_DATE_TABLE_ID, ): date}),
operator='AND'))
greater_than_date_query_list.append(
ComplexQuery(
ComplexQuery(operator='AND',
*[Query(**{'%s.%s' % (GREATER_THAN_DATE_TABLE_ID, k): \
v}) \
for k, v in non_date_value_dict.iteritems()]),
# 'Use explicitly Universal' otherwise DateTime
# search key will convert it to UTC one more time
Query(**{'%s.date' % (GREATER_THAN_DATE_TABLE_ID, ): date,
'range': 'nlt'}),
operator='AND'))
assert len(equal_date_query_list) == \
len(greater_than_date_query_list)
assert len(equal_date_query_list) > 0
equal_date_query = buildSQLQuery(query=ComplexQuery(operator='OR', *equal_date_query_list), query_table=None)['where_expression']
greater_than_date_query = buildSQLQuery(query=ComplexQuery(operator='OR', *greater_than_date_query_list), query_table=None)['where_expression']
inventory_stock_sql_kw = \
self._generateSQLKeywordDictFromKeywordDict(
table=EQUAL_DATE_TABLE_ID, sql_kw=sql_kw, new_kw=new_kw)
inventory_stock_where_query = \
inventory_stock_sql_kw.get('where_expression', '(1)')
assert isinstance(inventory_stock_where_query, basestring) \
and len(inventory_stock_where_query)
inventory_stock_sql_kw['where_expression'] = '(%s) AND (%s)' % \
(inventory_stock_where_query, equal_date_query)
where_query = stock_sql_kw.get('where_expression', '(1)')
assert isinstance(where_query, basestring) and len(where_query)
stock_sql_kw['where_expression'] = '(%s) AND (%s)' % \
(where_query, greater_than_date_query)
# Get initial inventory amount
initial_inventory_line_list = self.Resource_zGetInventoryList(
stock_table_id=EQUAL_DATE_TABLE_ID,
src__=src__, ignore_variation=ignore_variation,
standardise=standardise, omit_simulation=omit_simulation,
only_accountable=only_accountable,
selection_domain=selection_domain,
selection_report=selection_report, precision=precision,
inventory_list=inventory_list,
statistic=statistic,
quantity_unit_uid=quantity_unit_uid,
convert_quantity_result=convert_quantity_result,
**inventory_stock_sql_kw)
# Get delta inventory
delta_inventory_line_list = self.Resource_zGetInventoryList(
stock_table_id=GREATER_THAN_DATE_TABLE_ID,
src__=src__, ignore_variation=ignore_variation,
standardise=standardise, omit_simulation=omit_simulation,
only_accountable=only_accountable,
selection_domain=selection_domain,
selection_report=selection_report, precision=precision,
inventory_list=inventory_list,
statistic=statistic,
quantity_unit_uid=quantity_unit_uid,
convert_quantity_result=convert_quantity_result,
**stock_sql_kw)
# Match & add initial and delta inventories
if src__:
sql_source_list.extend((initial_inventory_line_list,
delta_inventory_line_list))
else:
if 'column_group_by' in new_kw:
group_by_id_list = []
group_by_id_list_append = group_by_id_list.append
for group_by_id in new_kw['column_group_by']:
if group_by_id == 'uid':
group_by_id_list_append('stock_uid')
else:
group_by_id_list_append(group_by_id)
def getInventoryListKey(line):
"""
Generate a key based on values used in SQL group_by
"""
return tuple([line[x] for x in group_by_id_list])
else:
def getInventoryListKey(line):
"""
No group by criterion, regroup everything.
"""
return 'dummy_key'
result_column_id_dict['inventory'] = None
result_column_id_dict['total_quantity'] = None
result_column_id_dict['total_price'] = None
def addLineValues(line_a=None, line_b=None):
"""
Addition columns of 2 lines and return a line with same
schema. If one of the parameters is None, returns the
other parameters.
Arithmetic modifications on additions:
None + x = x
None + None = None
"""
if line_a is None:
return line_b
if line_b is None:
return line_a
# Create a new Shared.DC.ZRDB.Results.Results.__class__
# instance to add the line values.
# the logic for the 5 lines below is taken from
# Shared.DC.ZRDB.Results.Results.__getitem__
Result = line_a.__class__
parent = line_a.aq_parent
result = Result((), parent)
if parent is not None:
result = result.__of__(parent)
for key in line_a.__record_schema__:
value = line_a[key]
if key in result_column_id_dict:
value_b = line_b[key]
if None not in (value, value_b):
result[key] = value + value_b
elif value is not None:
result[key] = value
else:
result[key] = value_b
elif line_a[key] == line_b[key]:
result[key] = line_a[key]
elif key not in ('date', 'stock_uid', 'path'):
LOG('InventoryTool.getInventoryList.addLineValues',
PROBLEM,
'mismatch for %s column: %s and %s' % \
(key, line_a[key], line_b[key]))
return result
inventory_list_dict = {}
for line_list in (initial_inventory_line_list,
delta_inventory_line_list):
for line in line_list:
line_key = getInventoryListKey(line)
line_a = inventory_list_dict.get(line_key)
inventory_list_dict[line_key] = addLineValues(line_a,
line)
## XXX: Returns a dict instead of an <r> instance
## As long as they are accessed like dicts it's ok, but...
#result = inventory_list_dict.values()
sorted_inventory_list = inventory_list_dict.values()
sort_on = new_kw.get('sort_on', tuple())
if len(sort_on) != 0:
def cmp_inventory_line(line_a, line_b):
"""
Compare 2 inventory lines and sort them according to
sort_on parameter.
"""
result = 0
for key, sort_direction in sort_on:
if not(key in line_a and key in line_b):
raise Exception, "Impossible to sort result since " \
"columns sort happens on are not available in " \
"result."
result = cmp(line_a[key], line_b[key])
if result != 0:
if len(sort_direction[0]) and \
sort_direction[0].upper() != 'A':
# Default sort is ascending, if a sort is given and
# it does not start with an 'A' then reverse sort.
# Tedious syntax checking is MySQL's job, and
# happened when queries were executed.
result *= -1
break
return result
sorted_inventory_list.sort(cmp_inventory_line)
result = Results((delta_inventory_line_list.\
_searchable_result_columns(),
tuple(sorted_inventory_list)))
else:
# Not all required full inventories are found
optimisation_success = False
else:
# Not enough criterions to trigger optimisation
optimisation_success = False
if not optimisation_success:
result = self.Resource_zGetInventoryList(
stock_table_id=default_stock_table,
src__=src__, ignore_variation=ignore_variation,
standardise=standardise, omit_simulation=omit_simulation,
only_accountable=only_accountable,
selection_domain=selection_domain,
selection_report=selection_report, precision=precision,
inventory_list=inventory_list, connection_id=connection_id,
statistic=statistic,
quantity_unit_uid=quantity_unit_uid,
convert_quantity_result=convert_quantity_result,
**stock_sql_kw)
if src__:
sql_source_list.append(result)
table=default_stock_table, sql_kw=sql_kw, new_kw=new_kw_copy)
stock_sql_kw.update(base_inventory_kw)
delta_result = self.Resource_zGetInventoryList(
**stock_sql_kw)
if src__:
sql_source_list.append(delta_result)
result = ';\n-- NEXT QUERY\n'.join(sql_source_list)
else:
if cached_result:
result = self._addBrainResults(delta_result, cached_result, new_kw)
else:
result = delta_result
return result
def getInventoryCacheLag(self):
"""
Returns a duration, in days, for stock cache management.
If data in stock cache is older than lag compared to query's date
(at_date or to_date), then it becomes a "soft miss": use found value,
but add a new entry to cache at query's date minus half the lag.
So this value should be:
- Small enough that few enough rows need to be table-scanned for
average queries (probably queries against current date).
- Large enough that few enough documents get modified past that date,
otherwise cache entries would be removed from cache all the time.
"""
return self.SimulationTool_getInventoryCacheLag()
def _getCachedInventoryList(self, to_date, sql_kw, stock_table_id, src__=False, **kw):
"""
Try to get a cached inventory list result
If not existing, fill the cache
"""
Resource_zGetInventoryList = self.Resource_zGetInventoryList
# Generate the SQL source without date parameter
# This will be the cache key
try:
no_date_kw = deepcopy(sql_kw)
except TypeError:
LOG("SimulationTool._getCachedInventoryList", WARNING,
"Failed copying sql_kw, disabling stock cache",
error=exc_info())
raise StockOptimisationError
no_date_sql_kw, no_date_new_kw = self._generateKeywordDict(**no_date_kw)
no_date_stock_sql_kw = self._generateSQLKeywordDictFromKeywordDict(
table=stock_table_id, sql_kw=no_date_sql_kw,
new_kw=no_date_new_kw)
kw.update(no_date_stock_sql_kw)
if src__:
sql_source_list = []
# Generate the cache key (md5 of query source)
sql_text_hash = md5(Resource_zGetInventoryList(
stock_table_id=stock_table_id,
src__=1,
**kw)).digest()
# Try to get result from cache
Resource_zGetInventoryCacheResult = self.Resource_zGetInventoryCacheResult
inventory_cache_kw = {
'query': sql_text_hash,
'date': to_date,
}
try:
cached_sql_result = Resource_zGetInventoryCacheResult(**inventory_cache_kw)
except ProgrammingError:
# First use of the optimisation, we need to create the table
LOG("SimulationTool._getCachedInventoryList", INFO,
"Creating inventory cache stock")
if src__:
sql_source_list.append(self.SimulationTool_zCreateInventoryCache(src__=1))
else:
self.SimulationTool_zCreateInventoryCache()
cached_sql_result = None
if src__:
sql_source_list.append(Resource_zGetInventoryCacheResult(src__=1, **inventory_cache_kw))
if cached_sql_result:
brain_result = loads(cached_sql_result[0].result)
# Rebuild the brains
cached_result = Results(
(brain_result['items'], brain_result['data']),
brains=getBrain(
Resource_zGetInventoryList.class_file_,
Resource_zGetInventoryList.class_name_,
),
parent=self,
)
else:
cached_result = []
cache_lag = self.getInventoryCacheLag()
if cached_sql_result and to_date - DateTime(cached_sql_result[0].date) < cache_lag:
cached_date = DateTime(cached_sql_result[0].date)
result = cached_result
else:
# Cache miss, or hit with old data: store a new entry in cache.
# Don't store it at to_date, as it risks being flushed soon (ie, when
# any document older than to_date gets reindexed in stock table).
# Don't store it at to_date - cache_lag, as it would risk expiring
# soon as we store it (except if to_date is fixed for many queries,
# which we cannot tell here).
# So store it at half the cache_lag before to_date.
cached_date = to_date - cache_lag / 2
new_cache_kw = deepcopy(sql_kw)
if cached_result:
# We can use cached result to generate new cache result
new_cache_kw['from_date'] = DateTime(cached_sql_result[0].date)
sql_kw, new_kw = self._generateKeywordDict(
to_date=cached_date,
**new_cache_kw)
kw.update(self._generateSQLKeywordDictFromKeywordDict(
table=stock_table_id,
sql_kw=sql_kw,
new_kw=new_kw,
)
)
new_result = Resource_zGetInventoryList(
stock_table_id=stock_table_id,
src__=src__,
**kw)
if src__:
sql_source_list.append(new_result)
else:
result = self._addBrainResults(new_result, cached_result, new_kw)
self.Resource_zInsertInventoryCacheResult(
query=sql_text_hash,
date=cached_date,
result=dumps({
'items': result.__items__,
'data': result._data,
}),
)
if src__:
result = sql_source_list
return result, cached_date
def _addBrainResults(self, first_result, second_result, new_kw):
"""
Build a Results which is the addition of two other result
"""
# This part defined key to group lines from different Results
group_by_id_list = []
group_by_id_list_append = group_by_id_list.append
for group_by_id in new_kw.get('column_group_by', []):
if group_by_id == 'uid':
group_by_id_list_append('stock_uid')
else:
group_by_id_list_append(group_by_id)
# Add related key group by
if 'select_list' in new_kw.get("related_key_dict_passthrough", []):
for group_by_id in new_kw["related_key_dict_passthrough"]['group_by']:
if group_by_id in new_kw["related_key_dict_passthrough"]["select_list"]:
group_by_id_list_append(group_by_id)
else:
# XXX-Aurel : to review & change, must prevent coming here before
raise ValueError, "Impossible to group by %s" %(group_by_id)
elif "group_by" in new_kw.get("related_key_dict_passthrough", []):
raise ValueError, "Impossible to group by %s" %(new_kw["related_key_dict_passthrough"]['group_by'],)
if len(group_by_id_list):
def getInventoryListKey(line):
"""
Generate a key based on values used in SQL group_by
"""
return tuple([line[x] for x in group_by_id_list])
else:
def getInventoryListKey(line):
"""
Return a dummy key, all line will be summed
"""
return "dummy"
result_column_id_dict = {
'inventory': None,
'total_quantity': None,
'total_price': None
}
def addLineValues(line_a=None, line_b=None):
"""
Add columns of 2 lines and return a line with same
schema. If one of the parameters is None, returns the
other parameters.
Arithmetic modifications on additions:
None + x = x
None + None = None
"""
if line_a is None:
return line_b
if line_b is None:
return line_a
# Create a new Shared.DC.ZRDB.Results.Results.__class__
# instance to add the line values.
# the logic for the 5 lines below is taken from
# Shared.DC.ZRDB.Results.Results.__getitem__
Result = line_a.__class__
parent = line_a.aq_parent
result = Result((), parent)
try:
# We must copy the path so that getObject works
setattr(result, 'path', line_a.path)
except ValueError: # XXX: ValueError ? really ?
# getInventory return no object, so no path available
pass
if parent is not None:
result = result.__of__(parent)
for key in line_a.__record_schema__:
value = line_a[key]
if key in result_column_id_dict:
value_b = line_b[key]
if None not in (value, value_b):
result[key] = value + value_b
elif value is not None:
result[key] = value
else:
result[key] = value_b
elif line_a[key] == line_b[key]:
result[key] = line_a[key]
elif key not in ('date', 'stock_uid', 'path'):
LOG('InventoryTool.getInventoryList.addLineValues',
PROBLEM,
'mismatch for %s column: %s and %s' % (
key, line_a[key], line_b[key]))
return result
# Add lines
inventory_list_dict = {}
for line_list in (first_result, second_result):
for line in line_list:
line_key = getInventoryListKey(line)
line_a = inventory_list_dict.get(line_key)
inventory_list_dict[line_key] = addLineValues(line_a, line)
sorted_inventory_list = inventory_list_dict.values()
# Sort results manually when required
sort_on = new_kw.get('sort_on')
if sort_on:
def cmp_inventory_line(line_a, line_b):
"""
Compare 2 inventory lines and sort them according to
sort_on parameter.
"""
result = 0
for key, sort_direction in sort_on:
try:
result = cmp(line_a[key], line_b[key])
except KeyError:
raise Exception('Impossible to sort result since columns sort '
'happens on are not available in result: %r' % (key, ))
if result:
if not sort_direction.upper().startswith('A'):
# Default sort is ascending, if a sort is given and
# it does not start with an 'A' then reverse sort.
# Tedious syntax checking is MySQL's job, and
# happened when queries were executed.
result *= -1
break
return result
sorted_inventory_list.sort(cmp_inventory_line)
# Brain is rebuild properly using tuple not r instance
column_list = first_result._searchable_result_columns()
column_name_list = [x['name'] for x in column_list]
# Rebuild a result object based on added results
Resource_zGetInventoryList = self.Resource_zGetInventoryList
return Results(
(column_list, tuple([tuple([getattr(y, x) for x in column_name_list]) \
for y in sorted_inventory_list])),
parent=self,
brains=getBrain(
Resource_zGetInventoryList.class_file_,
Resource_zGetInventoryList.class_name_,
),
)
security.declareProtected(Permissions.AccessContentsInformation,
'getConvertedInventoryList')
def getConvertedInventoryList(self, simulation_period='', **kw):
......
......@@ -15,9 +15,6 @@
<key>effective_date</key>
<key>expiration_date</key>
<key>grouping_date</key>
<key>inventory.date</key>
<key>inventory_stock.date</key>
<key>inventory_stock.mirror_date</key>
<key>item.date</key>
<key>mirror_date</key>
<key>modification_date</key>
......@@ -34,4 +31,4 @@
<key>stop_date_range_min</key>
<key>versioning.effective_date</key>
<key>versioning.expiration_date</key>
</key_list>
\ No newline at end of file
</key_list>
<catalog_method>
<item key="sql_uncatalog_object" type="int">
<value>1</value>
</item>
<item key="_is_filtered_archive" type="int">
<value>1</value>
</item>
<item key="_filter_expression_archive" type="str">
<value>python: context.isInventory()</value>
</item>
<item key="_filter_expression_cache_key_archive" type="tuple">
<value>portal_type</value>
</item>
</catalog_method>
<catalog_method>
<item key="sql_catalog_object_list" type="int">
<value>1</value>
</item>
</catalog_method>
<?xml version="1.0"?>
<ZopeData>
<record id="1" aka="AAAAAAAAAAE=">
<pickle>
<global name="SQL" module="Products.ZSQLMethods.SQL"/>
</pickle>
<pickle>
<dictionary>
<item>
<key> <string>arguments_src</string> </key>
<value> <string>isInventory\r\n
uid\r\n
getDestinationUid\r\n
getDestinationSectionUid\r\n
getDestinationPaymentUid\r\n
getStartDate\r\n
isFullInventory\r\n
getSimulationState</string> </value>
</item>
<item>
<key> <string>connection_id</string> </key>
<value> <string>erp5_sql_connection</string> </value>
</item>
<item>
<key> <string>id</string> </key>
<value> <string>z_catalog_inventory_list</string> </value>
</item>
<item>
<key> <string>src</string> </key>
<value> <string encoding="cdata"><![CDATA[
<dtml-let row_list="[]">\n
\n
<dtml-in prefix="loop" expr="_.range(_.len(uid))">\n
<dtml-if expr="isInventory[loop_item]">\n
<dtml-if expr="getStartDate[loop_item] is not None">\n
<dtml-call expr="row_list.append((\n
uid[loop_item],\n
getDestinationUid[loop_item],\n
getDestinationSectionUid[loop_item],\n
getDestinationPaymentUid[loop_item],\n
getStartDate[loop_item],\n
isFullInventory[loop_item],\n
getSimulationState[loop_item]))">\n
</dtml-if>\n
</dtml-if>\n
</dtml-in>\n
\n
<dtml-if expr="len(row_list)">\n
REPLACE INTO\n
inventory\n
(`uid`, `node_uid`, `section_uid`, `payment_uid`, `date`, `is_full_inventory`, `simulation_state`)\n
VALUES\n
<dtml-in prefix="row" expr="row_list">\n
(<dtml-sqlvar expr="row_item[0]" type="int">,\n
<dtml-sqlvar expr="row_item[1]" type="int" optional>,\n
<dtml-sqlvar expr="row_item[2]" type="int" optional>,\n
<dtml-sqlvar expr="row_item[3]" type="int" optional>,\n
<dtml-sqlvar expr="row_item[4]" type="datetime">,\n
<dtml-sqlvar expr="row_item[5] or 0" type="int">,\n
<dtml-sqlvar expr="row_item[6]" type="string">\n
)<dtml-if sequence-end><dtml-else>,</dtml-if>\n
</dtml-in>\n
</dtml-if>\n
</dtml-let>
]]></string> </value>
</item>
<item>
<key> <string>title</string> </key>
<value> <string></string> </value>
</item>
</dictionary>
</pickle>
</record>
</ZopeData>
<?xml version="1.0"?>
<ZopeData>
<record id="1" aka="AAAAAAAAAAE=">
<pickle>
<global name="SQL" module="Products.ZSQLMethods.SQL"/>
</pickle>
<pickle>
<dictionary>
<item>
<key> <string>arguments_src</string> </key>
<value> <string>uid\r\n
getResourceUid\r\n
getInventoriatedQuantity\r\n
getSourceUid\r\n
getDestinationUid\r\n
getSourceSectionUid\r\n
getDestinationSectionUid\r\n
isMovement\r\n
getSourcePaymentUid\r\n
getDestinationPaymentUid\r\n
getSourceFunctionUid\r\n
getDestinationFunctionUid\r\n
getSourceProjectUid\r\n
getDestinationProjectUid\r\n
getSourceFundingUid\r\n
getDestinationFundingUid\r\n
getSourcePaymentRequestUid\r\n
getDestinationPaymentRequestUid\r\n
getSimulationState\r\n
getSourceInventoriatedTotalAssetPrice\r\n
getDestinationInventoriatedTotalAssetPrice\r\n
getStartDate\r\n
getStopDate\r\n
isAccountable\r\n
getPortalType\r\n
getVariationText\r\n
getSubVariationText</string> </value>
</item>
<item>
<key> <string>connection_id</string> </key>
<value> <string>erp5_sql_connection</string> </value>
</item>
<item>
<key> <string>id</string> </key>
<value> <string>z_catalog_inventory_stock_list</string> </value>
</item>
<item>
<key> <string>src</string> </key>
<value> <string encoding="cdata"><![CDATA[
DELETE FROM\n
inventory_stock\n
WHERE\n
<dtml-in uid>\n
uid=<dtml-sqlvar sequence-item type="int"><dtml-if sequence-end><dtml-else> OR </dtml-if>\n
</dtml-in>\n
\n
<dtml-var sql_delimiter>\n
\n
<dtml-let row_list="[]" uid_dict="{}">\n
<dtml-in prefix="loop" expr="_.range(_.len(uid))">\n
<dtml-if "isMovement[loop_item] and isAccountable[loop_item] and getResourceUid[loop_item]">\n
<dtml-if "getDestinationUid[loop_item]">\n
<dtml-call expr="uid_dict.update({uid[loop_item]: uid_dict.get(uid[loop_item], -1) + 1})">\n
<dtml-call expr="row_list.append([\n
uid[loop_item], \n
uid_dict[uid[loop_item]],\n
getDestinationUid[loop_item],\n
getDestinationSectionUid[loop_item],\n
getDestinationPaymentUid[loop_item],\n
getDestinationFunctionUid[loop_item],\n
getDestinationProjectUid[loop_item], \n
getDestinationFundingUid[loop_item], \n
getDestinationPaymentRequestUid[loop_item], \n
getSourceSectionUid[loop_item], \n
getSourceUid[loop_item], \n
getResourceUid[loop_item],\n
getInventoriatedQuantity[loop_item],\n
getStopDate[loop_item], \n
getStartDate[loop_item], \n
getDestinationInventoriatedTotalAssetPrice[loop_item], \n
getPortalType[loop_item], \n
getSimulationState[loop_item], \n
getVariationText[loop_item],\n
getSubVariationText[loop_item]])">\n
</dtml-if>\n
<dtml-if "getSourceUid[loop_item]">\n
<dtml-call expr="uid_dict.update({uid[loop_item]: uid_dict.get(uid[loop_item], -1) + 1})">\n
<dtml-call expr="row_list.append([\n
uid[loop_item], \n
uid_dict[uid[loop_item]],\n
getSourceUid[loop_item],\n
getSourceSectionUid[loop_item],\n
getSourcePaymentUid[loop_item],\n
getSourceFunctionUid[loop_item],\n
getSourceProjectUid[loop_item], \n
getSourceFundingUid[loop_item], \n
getSourcePaymentRequestUid[loop_item], \n
getDestinationSectionUid[loop_item], \n
getDestinationUid[loop_item], \n
getResourceUid[loop_item],\n
-(getInventoriatedQuantity[loop_item] or 0), \n
getStartDate[loop_item], \n
getStopDate[loop_item],\n
getSourceInventoriatedTotalAssetPrice[loop_item], \n
getPortalType[loop_item], \n
getSimulationState[loop_item], \n
getVariationText[loop_item],\n
getSubVariationText[loop_item]])">\n
</dtml-if>\n
</dtml-if>\n
</dtml-in> \n
\n
<dtml-if "row_list">\n
INSERT INTO\n
inventory_stock\n
VALUES\n
<dtml-in prefix="row" expr="row_list">\n
(\n
<dtml-sqlvar expr="row_item[0]" type="int">,\n
<dtml-sqlvar expr="row_item[1]" type="int">,\n
<dtml-sqlvar expr="row_item[2]" type="int">,\n
<dtml-sqlvar expr="row_item[3]" type="int" optional>,\n
<dtml-sqlvar expr="row_item[4]" type="int" optional>,\n
<dtml-sqlvar expr="row_item[5]" type="int" optional>,\n
<dtml-sqlvar expr="row_item[6]" type="int" optional>,\n
<dtml-sqlvar expr="row_item[7]" type="int" optional>,\n
<dtml-sqlvar expr="row_item[8]" type="int" optional>,\n
<dtml-sqlvar expr="row_item[9]" type="int" optional>,\n
<dtml-sqlvar expr="row_item[10]" type="int" optional>,\n
<dtml-sqlvar expr="row_item[11]" type="int">,\n
<dtml-sqlvar expr="row_item[12]" type="float" optional>,\n
1, <dtml-comment>only accountable</dtml-comment>\n
<dtml-sqlvar expr="row_item[13]" type="datetime" optional>,\n
<dtml-sqlvar expr="row_item[14]" type="datetime" optional>,\n
<dtml-sqlvar expr="row_item[15]" type="float" optional>,\n
<dtml-sqlvar expr="row_item[16]" type="string" optional>,\n
<dtml-sqlvar expr="row_item[17]" type="string" optional>,\n
<dtml-sqlvar expr="row_item[18]" type="string" optional>,\n
<dtml-sqlvar expr="row_item[19]" type="string" optional>\n
)\n
<dtml-if sequence-end><dtml-else>,</dtml-if>\n
</dtml-in>\n
</dtml-if>\n
</dtml-let>\n
]]></string> </value>
</item>
<item>
<key> <string>title</string> </key>
<value> <string></string> </value>
</item>
</dictionary>
</pickle>
</record>
</ZopeData>
......@@ -92,7 +92,6 @@ WHERE\n
;\n
\n
<dtml-var "\'\\0\'">\n
\n
<dtml-let row_list="[]" uid_dict="{}">\n
<dtml-in prefix="loop" expr="_.range(_.len(uid))">\n
<dtml-if "not(isInventoryMovement[loop_item]) and isMovement[loop_item] and getResourceUid[loop_item]">\n
......@@ -152,7 +151,6 @@ WHERE\n
</dtml-if>\n
</dtml-if>\n
</dtml-in> \n
\n
<dtml-if "row_list">\n
INSERT INTO\n
stock\n
......
<?xml version="1.0"?>
<ZopeData>
<record id="1" aka="AAAAAAAAAAE=">
<pickle>
<global name="SQL" module="Products.ZSQLMethods.SQL"/>
</pickle>
<pickle>
<dictionary>
<item>
<key> <string>arguments_src</string> </key>
<value> <string></string> </value>
</item>
<item>
<key> <string>connection_id</string> </key>
<value> <string>erp5_sql_connection</string> </value>
</item>
<item>
<key> <string>id</string> </key>
<value> <string>z_create_inventory</string> </value>
</item>
<item>
<key> <string>src</string> </key>
<value> <string>CREATE TABLE `inventory` (\n
`uid` BIGINT(20) UNSIGNED NOT NULL,\n
`node_uid` BIGINT(20) UNSIGNED NULL,\n
`section_uid` BIGINT(20) UNSIGNED NULL,\n
`payment_uid` BIGINT(20) UNSIGNED NULL,\n
`date` DATETIME NOT NULL,\n
`is_full_inventory` BOOL NOT NULL DEFAULT FALSE,\n
`simulation_state` VARCHAR(255) NOT NULL DEFAULT \'\',\n
PRIMARY KEY `uid` (`uid`),\n
KEY `node_index` (`is_full_inventory`, `simulation_state`, `node_uid`),\n
KEY `section_index` (`is_full_inventory`, `simulation_state`, `section_uid`),\n
KEY `payment_index` (`is_full_inventory`, `simulation_state`, `payment_uid`)\n
) ENGINE=InnoDB</string> </value>
</item>
<item>
<key> <string>title</string> </key>
<value> <string></string> </value>
</item>
</dictionary>
</pickle>
</record>
</ZopeData>
<?xml version="1.0"?>
<ZopeData>
<record id="1" aka="AAAAAAAAAAE=">
<pickle>
<global name="SQL" module="Products.ZSQLMethods.SQL"/>
</pickle>
<pickle>
<dictionary>
<item>
<key> <string>arguments_src</string> </key>
<value> <string></string> </value>
</item>
<item>
<key> <string>connection_id</string> </key>
<value> <string>erp5_sql_connection</string> </value>
</item>
<item>
<key> <string>id</string> </key>
<value> <string>z_create_inventory_stock</string> </value>
</item>
<item>
<key> <string>src</string> </key>
<value> <string>CREATE TABLE `inventory_stock` (\n
`uid` BIGINT UNSIGNED NOT NULL,\n
`order_id` TINYINT UNSIGNED NOT NULL,\n
`node_uid` BIGINT UNSIGNED,\n
`section_uid` BIGINT UNSIGNED,\n
`payment_uid` BIGINT UNSIGNED,\n
`function_uid` BIGINT UNSIGNED,\n
`project_uid` BIGINT UNSIGNED,\n
`funding_uid` BIGINT UNSIGNED,\n
`payment_request_uid` BIGINT UNSIGNED,\n
`mirror_section_uid` BIGINT UNSIGNED,\n
`mirror_node_uid` BIGINT UNSIGNED,\n
`resource_uid` BIGINT UNSIGNED,\n
`quantity` real ,\n
`is_accountable` BOOLEAN,\n
`date` datetime,\n
`mirror_date` datetime,\n
`total_price` real ,\n
`portal_type` VARCHAR(255),\n
`simulation_state` varchar(255) default \'\',\n
`variation_text` VARCHAR(255),\n
`sub_variation_text` VARCHAR(255),\n
PRIMARY KEY (`uid`, `order_id`),\n
KEY `quantity` (`quantity`),\n
KEY `section_uid` (`section_uid`),\n
KEY `mirror_section_uid` (`mirror_section_uid`),\n
KEY `mirror_node_uid` (`mirror_node_uid`),\n
KEY `node_uid` (`node_uid`),\n
KEY `payment_uid` (`payment_uid`),\n
KEY `function_uid` (`function_uid`),\n
KEY `project_uid` (`project_uid`),\n
KEY `simulation_state` (`simulation_state`),\n
KEY `resource_node_uid` (`resource_uid`, `node_uid`),\n
KEY `resource_section_node_uid` (`resource_uid`, `section_uid`, `node_uid`, `simulation_state`),\n
KEY `date` (`date`)\n
) ENGINE=InnoDB\n
</string> </value>
</item>
<item>
<key> <string>title</string> </key>
<value> <string></string> </value>
</item>
</dictionary>
</pickle>
</record>
</ZopeData>
......@@ -3,8 +3,6 @@
<key>catalog</key>
<key>category</key>
<key>delivery</key>
<key>inventory</key>
<key>inventory_stock</key>
<key>item</key>
<key>predicate</key>
<key>predicate_category</key>
......
......@@ -12,9 +12,6 @@ delivery.start_date_range_min
delivery.stop_date
delivery.stop_date_range_max
delivery.stop_date_range_min
inventory.date
inventory_stock.date
inventory_stock.mirror_date
item.date
mirror_date
modification_date
......
......@@ -7,8 +7,6 @@ erp5_mysql_innodb/z0_drop_alarm
erp5_mysql_innodb/z0_drop_catalog
erp5_mysql_innodb/z0_drop_category
erp5_mysql_innodb/z0_drop_delivery
erp5_mysql_innodb/z0_drop_inventory
erp5_mysql_innodb/z0_drop_inventory_stock
erp5_mysql_innodb/z0_drop_item
erp5_mysql_innodb/z0_drop_measure
erp5_mysql_innodb/z0_drop_predicate
......@@ -23,8 +21,6 @@ erp5_mysql_innodb/z0_drop_translation
erp5_mysql_innodb/z0_drop_versioning
erp5_mysql_innodb/z0_uncatalog_alarm
erp5_mysql_innodb/z0_uncatalog_category
erp5_mysql_innodb/z0_uncatalog_inventory
erp5_mysql_innodb/z0_uncatalog_inventory_stock
erp5_mysql_innodb/z0_uncatalog_item
erp5_mysql_innodb/z0_uncatalog_measure
erp5_mysql_innodb/z0_uncatalog_predicate
......@@ -35,8 +31,6 @@ erp5_mysql_innodb/z0_uncatalog_transformation
erp5_mysql_innodb/z0_uncatalog_versioning
erp5_mysql_innodb/z_catalog_alarm_list
erp5_mysql_innodb/z_catalog_delivery_list
erp5_mysql_innodb/z_catalog_inventory_list
erp5_mysql_innodb/z_catalog_inventory_stock_list
erp5_mysql_innodb/z_catalog_item_list
erp5_mysql_innodb/z_catalog_measure_list
erp5_mysql_innodb/z_catalog_movement_category_list
......@@ -58,8 +52,6 @@ erp5_mysql_innodb/z_create_alarm
erp5_mysql_innodb/z_create_catalog
erp5_mysql_innodb/z_create_category
erp5_mysql_innodb/z_create_delivery
erp5_mysql_innodb/z_create_inventory
erp5_mysql_innodb/z_create_inventory_stock
erp5_mysql_innodb/z_create_item
erp5_mysql_innodb/z_create_measure
erp5_mysql_innodb/z_create_predicate
......
......@@ -2,8 +2,6 @@ catalog
category
alarm
delivery
inventory
inventory_stock
item
predicate
predicate_category
......
......@@ -184,7 +184,7 @@ class TestInventory(TestOrderMixin, ERP5TypeTestCase):
inventory_list.append(inventory)
sequence.edit(inventory_list = inventory_list)
def createInventory(self, sequence=None):
def createInventory(self, sequence=None, full=False):
"""
"""
portal = self.getPortal()
......@@ -193,7 +193,8 @@ class TestInventory(TestOrderMixin, ERP5TypeTestCase):
inventory = inventory_module.newContent(portal_type = self.inventory_portal_type)
inventory.edit(destination_value = sequence.get('node'),
destination_section_value = sequence.get('section'),
start_date = DateTime() + 1
start_date = DateTime() + 1,
full_inventory=full,
)
inventory_list.append(inventory)
sequence.edit(inventory_list=inventory_list)
......@@ -213,6 +214,66 @@ class TestInventory(TestOrderMixin, ERP5TypeTestCase):
inventory_list.append(inventory)
sequence.edit(inventory_list=inventory_list)
def stepCreateFullInventory(self, sequence=None, sequence_list=None, **kw):
"""
Create a full Inventory object for Inventory Module testing
"""
inventory = self.createInventory(sequence=sequence)
inventory_list = sequence.get('inventory_list',[])
inventory.edit(full_inventory=True)
inventory_line = inventory.newContent(
portal_type = self.inventory_line_portal_type,
resource_value = sequence.get("second_resource"),
inventory = 101)
inventory.deliver()
inventory_list.append(inventory)
sequence.edit(inventory_list=inventory_list)
def stepCreatePartialInventoryMultipleResource(self, sequence=None, sequence_list=None, **kw):
"""
Create a partial inventory object for one resource
"""
inventory = self.createInventory(sequence=sequence)
inventory_list = sequence.get('inventory_list',[])
inventory.edit(full_inventory=False)
inventory_line = inventory.newContent(
portal_type = self.inventory_line_portal_type,
resource_value = sequence.get("second_resource"),
inventory = 101)
inventory.deliver()
inventory_list.append(inventory)
sequence.edit(inventory_list=inventory_list)
def stepTestPartialInventoryMultipleResource(self, sequence=None, sequence_list=None, **kw):
"""
Test partial inventory behavior with multiple resource
"""
inventory_list = sequence.get('inventory_list')
simulation = self.getPortal().portal_simulation
# First resource, must not have changed
inventory = simulation.getCurrentInventory(
resource = sequence.get("resource").getRelativeUrl(),
section = sequence.get('section').getRelativeUrl(),
node = sequence.get('node').getRelativeUrl(),
)
self.assertEquals(inventory, 100.,
'section=%s, node=%s' % (
sequence.get('section').getRelativeUrl(),
sequence.get('node').getRelativeUrl()))
# second resource, must be 101
inventory = simulation.getCurrentInventory(
resource = sequence.get("second_resource").getRelativeUrl(),
section = sequence.get('section').getRelativeUrl(),
node = sequence.get('node').getRelativeUrl(),
)
self.assertEquals(inventory, 101.,
'section=%s, node=%s' % (
sequence.get('section').getRelativeUrl(),
sequence.get('node').getRelativeUrl()))
def stepCreateSingleVariatedInventory(self, sequence=None, sequence_list=None, **kw):
"""
Create a single Inventory object for Inventory Module testing
......@@ -231,19 +292,32 @@ class TestInventory(TestOrderMixin, ERP5TypeTestCase):
variation_category_list = category_list,
mapped_value_property_list = ['quantity'],
)
category_list = sequence.get('variation_2')
# When checking the not full inventory function, quantity must remain the same if
# no inventory line defined for a variation
inventory.deliver()
def stepCreateFullVariatedInventory(self, sequence=None, sequence_list=None, **kw):
"""
Create a single full Inventory object for Inventory Module testing
"""
inventory = self.createInventory(sequence=sequence, full=True)
inventory_line = inventory.newContent(portal_type = self.inventory_line_portal_type)
category_list = sequence.get('variation_1')
inventory_line.edit(resource_value = sequence.get('resource'),
variation_category_list=category_list
)
cell = inventory_line.newCell(base_id='movement',*category_list)
quantity=0
cell.edit(
quantity = quantity,
quantity = 55,
predicate_category_list = category_list,
variation_category_list = category_list,
mapped_value_property_list = ['quantity'],
)
inventory_line = inventory.newContent(
portal_type = self.inventory_line_portal_type,
resource_value = sequence.get("second_resource"),
inventory = 101)
inventory.deliver()
def stepCreatePackingListForModule(self, sequence=None,
......@@ -1762,10 +1836,11 @@ class TestInventory(TestOrderMixin, ERP5TypeTestCase):
variation_category_list.sort()
variation_text = '\n'.join(variation_category_list)
inventory = simulation.getCurrentInventory(
section=sequence.get('section').getRelativeUrl(),
node=sequence.get('node').getRelativeUrl(),
variation_text=variation_text
)
resource = sequence.get("resource").getRelativeUrl(),
section = sequence.get('section').getRelativeUrl(),
node = sequence.get('node').getRelativeUrl(),
variation_text = variation_text
)
self.assertEquals(inventory, quantity)
def stepTestInitialVariatedInventory(self, sequence=None, sequence_list=None, **kw):
......@@ -1806,10 +1881,51 @@ class TestInventory(TestOrderMixin, ERP5TypeTestCase):
self.checkVariatedInventory(variation_category_list=variation_category_list,
quantity=quantity,sequence=sequence)
variation_category_list = sequence.get('variation_2')
quantity = 3
self.checkVariatedInventory(variation_category_list=variation_category_list,
quantity=quantity,sequence=sequence)
def stepTestVariatedInventoryNonDefaultQuantityUnitAfterInventory(self, sequence=None, sequence_list=None, **kw):
"""
Test Inventory Module behavior
"""
resource = sequence.get('resource')
variation_category_list = sequence.get('variation_1')
quantity = 5
self.checkVariatedInventory(variation_category_list=variation_category_list,
quantity=quantity,sequence=sequence)
variation_category_list = sequence.get('variation_2')
quantity = 300
self.checkVariatedInventory(variation_category_list=variation_category_list,
quantity=quantity,sequence=sequence)
def stepTestFullVariatedInventory(self, sequence=None, sequence_list=None, **kw):
"""
Test full inventory with variated resource
"""
resource = sequence.get('resource')
variation_category_list = sequence.get('variation_1')
# Test first resource
quantity = 55
self.checkVariatedInventory(variation_category_list=variation_category_list,
quantity=quantity,sequence=sequence)
variation_category_list = sequence.get('variation_2')
quantity = 0
self.checkVariatedInventory(variation_category_list=variation_category_list,
quantity=quantity,sequence=sequence)
# second resource, must be 101
simulation = self.getPortal().portal_simulation
inventory = simulation.getCurrentInventory(
resource = sequence.get("second_resource").getRelativeUrl(),
section = sequence.get('section').getRelativeUrl(),
node = sequence.get('node').getRelativeUrl(),
)
self.assertEquals(inventory, 101.,
'section=%s, node=%s' % (
sequence.get('section').getRelativeUrl(),
sequence.get('node').getRelativeUrl()))
def stepTestInventoryModule(self, sequence=None, sequence_list=None, **kw):
"""
Test Inventory Module behavior
......@@ -1832,6 +1948,36 @@ class TestInventory(TestOrderMixin, ERP5TypeTestCase):
step += 1
sequence.edit(step=step)
def stepTestFullInventory(self, sequence=None, sequence_list=None, **kw):
"""
Test Full inventory behavior
"""
inventory_list = sequence.get('inventory_list')
simulation = self.getPortal().portal_simulation
# First resource, must be zero
inventory = simulation.getCurrentInventory(
resource = sequence.get("resource").getRelativeUrl(),
section = sequence.get('section').getRelativeUrl(),
node = sequence.get('node').getRelativeUrl(),
)
self.assertEquals(inventory, 0.,
'section=%s, node=%s' % (
sequence.get('section').getRelativeUrl(),
sequence.get('node').getRelativeUrl()))
# second resource, must be 101
inventory = simulation.getCurrentInventory(
resource = sequence.get("second_resource").getRelativeUrl(),
section = sequence.get('section').getRelativeUrl(),
node = sequence.get('node').getRelativeUrl(),
)
self.assertEquals(inventory, 101.,
'section=%s, node=%s' % (
sequence.get('section').getRelativeUrl(),
sequence.get('node').getRelativeUrl()))
def stepModifyFirstInventory(self, sequence=None, sequence_list=None, **kw):
"""
Modify the first entered Inventory, to test the quantity change
......@@ -1845,6 +1991,27 @@ class TestInventory(TestOrderMixin, ERP5TypeTestCase):
quantity=sum([x.getQuantity() for x in aggregate_value_list]))
def stepCreateNotVariatedSecondResource(self,sequence=None,
sequence_list=None,
**kw):
"""
Create a second resource with no variation
"""
portal = self.getPortal()
resource_module = portal.getDefaultModule(self.resource_portal_type)
resource = resource_module.newContent(portal_type=self.resource_portal_type)
resource.edit(
title = "NotVariatedSecondResource%s" % resource.getId(),
industrial_phase_list=["phase1", "phase2"],
product_line = 'apparel'
)
sequence.edit( second_resource = resource )
resource_list = sequence.get('resource_list',default=[])
resource_list.append(resource)
sequence.edit( resource_list = resource_list )
def test_01_getInventory(self, quiet=0, run=run_all_test):
"""
Test the getInventory methods
......@@ -1966,7 +2133,7 @@ class TestInventory(TestOrderMixin, ERP5TypeTestCase):
stepTestInitialVariatedNonDefaultQuantityUnitInventory \
stepCreateSingleVariatedInventory \
stepTic \
stepTestVariatedInventoryAfterInventory \
stepTestVariatedInventoryNonDefaultQuantityUnitAfterInventory \
'
sequence_list.addSequenceString(sequence_string)
sequence_list.play(self)
......@@ -2017,6 +2184,83 @@ class TestInventory(TestOrderMixin, ERP5TypeTestCase):
resource_uid=product.getUid()),
0)
def test_06_FullInventory(self, quiet=0, run=run_all_test):
"""
Test the full inventory behavior
"""
if not run: return
sequence_list = SequenceList()
sequence_string = 'stepCreateOrganisationsForModule \
stepCreateNotVariatedResource \
stepCreateNotVariatedSecondResource \
stepCreateItemList \
stepCreatePackingListForModule \
stepTic \
stepCreatePackingListLine \
stepTic \
stepDeliverPackingList \
stepTic \
stepCreateFullInventory \
stepTic \
stepTestFullInventory \
'
sequence_list.addSequenceString(sequence_string)
sequence_list.play(self)
def test_07_FullVariatedInventory(self, quiet=0, run=run_all_test):
"""
Test the full inventory behavior with variated resource
"""
if not run: return
sequence_list = SequenceList()
sequence_string = 'stepCreateOrganisationsForModule \
stepCreateVariatedResource \
stepCreateNotVariatedSecondResource \
stepCreateItemList \
stepCreatePackingListForModule \
stepTic \
stepCreateVariatedPackingListLine \
stepTic \
stepDeliverPackingList \
stepTic \
stepCreateFullVariatedInventory \
stepTic \
stepTestFullVariatedInventory \
'
sequence_list.addSequenceString(sequence_string)
sequence_list.play(self)
def test_08_PartialInventoryMultipleResource(self, quiet=0, run=run_all_test):
"""
Test behaviour of partial inventory with multiple resource
defining inventory of resource B must not modify inventory of resource A
"""
if not run: return
sequence_list = SequenceList()
sequence_string = 'stepCreateOrganisationsForModule \
stepCreateNotVariatedResource \
stepCreateNotVariatedSecondResource \
stepCreateItemList \
stepCreatePackingListForModule \
stepTic \
stepCreatePackingListLine \
stepTic \
stepDeliverPackingList \
stepTic \
stepCreatePartialInventoryMultipleResource \
stepTic \
stepTestPartialInventoryMultipleResource \
'
sequence_list.addSequenceString(sequence_string)
sequence_list.play(self)
def test_suite():
suite = unittest.TestSuite()
suite.addTest(unittest.makeSuite(TestInventory))
......
......@@ -38,14 +38,13 @@ import unittest
from AccessControl.SecurityManagement import newSecurityManager
from DateTime import DateTime
from Testing import ZopeTestCase
from MySQLdb import ProgrammingError
from Products.ERP5Type.tests.ERP5TypeTestCase import ERP5TypeTestCase
from Products.ERP5Type.tests.utils import reindex
from Products.ERP5Type.tests.backportUnittest import expectedFailure
from Products.DCWorkflow.DCWorkflow import ValidationFailed
from Products.ERP5Type.Base import _aq_reset
from Products.ERP5Type.tests.utils import createZODBPythonScript
from Products.ERP5.Tool.SimulationTool import MYSQL_MIN_DATETIME_RESOLUTION
class InventoryAPITestCase(ERP5TypeTestCase):
"""Base class for Inventory API Tests {{{
......@@ -98,7 +97,7 @@ class InventoryAPITestCase(ERP5TypeTestCase):
self.portal.newContent(portal_type='Folder',
id='testing_folder')
self.folder = self.portal.testing_folder
self.section = self._makeOrganisation(title='Section')
self.other_section = self._makeOrganisation(title='Other Section')
self.node = self._makeOrganisation(title='Node')
......@@ -143,7 +142,7 @@ class InventoryAPITestCase(ERP5TypeTestCase):
'Associate', 'Auditor', 'Author'], [])
user = uf.getUserById('alex').__of__(uf)
newSecurityManager(None, user)
def createCategories(self):
"""Create the categories for our test. """
# create categories
......@@ -163,7 +162,7 @@ class InventoryAPITestCase(ERP5TypeTestCase):
self.getCategoryTool().restrictedTraverse(cat_string),
cat_string)
self.tic()
def getNeededCategoryList(self):
"""return a list of categories that should be created."""
return ( 'region/level1/level2',
......@@ -177,15 +176,16 @@ class InventoryAPITestCase(ERP5TypeTestCase):
'function/function1/function2',
# we create a huge group category for consolidation tests
) + self.GROUP_CATEGORIES + self.VARIATION_CATEGORIES
def getBusinessTemplateList(self):
""" erp5_trade is required for transit_simulation_state
erp5_apparel is required for item
"""
return ('erp5_base', 'erp5_pdm', 'erp5_dummy_movement', 'erp5_simulation',
return ('erp5_core_proxy_field_legacy', 'erp5_base', 'erp5_pdm',
'erp5_dummy_movement', 'erp5_simulation',
'erp5_trade', 'erp5_apparel', 'erp5_project',
'erp5_configurator_standard_trade_template',
'erp5_simulation_test')
'erp5_simulation_test', 'erp5_stock_cache')
# TODO: move this to a base class {{{
@reindex
......@@ -210,7 +210,7 @@ class InventoryAPITestCase(ERP5TypeTestCase):
portal_type='Sale Packing List',
specialise=self.business_process,
**kw)
@reindex
def _makeSaleInvoice(self, created_by_builder=0, **kw):
"""Creates a sale invoice."""
......@@ -245,7 +245,7 @@ class InventoryAPITestCase(ERP5TypeTestCase):
kw.setdefault('resource_value', self.resource)
mvt.edit(**kw)
return mvt
@reindex
def _makeSimulationMovement(self, **kw):
"""Creates a simulation movement.
......@@ -316,7 +316,7 @@ class TestInventory(InventoryAPITestCase):
self.assertInventoryEquals(100, section_category='group/level1')
self.assertInventoryEquals(100, section_category='group/level1/level2')
self.assertInventoryEquals(0, section_category='group/anotherlevel')
# section category can be a list
self.assertInventoryEquals(100,
section_category=['group/anotherlevel', 'group/level1'])
......@@ -329,7 +329,7 @@ class TestInventory(InventoryAPITestCase):
self.tic()
self.assertInventoryEquals(100,
section_category_strict_membership=['group/level1'])
# non existing values to section_category are not silently ignored, but
# raises an exception
self.assertRaises(ValueError,
......@@ -356,7 +356,7 @@ class TestInventory(InventoryAPITestCase):
self.tic()
self.assertInventoryEquals(100,
mirror_section_category_strict_membership=['group/level1'])
# non existing values to section_category are not silently ignored, but
# raises an exception
self.assertRaises(ValueError,
......@@ -375,7 +375,7 @@ class TestInventory(InventoryAPITestCase):
self.tic()
self.assertInventoryEquals(100,
node_category_strict_membership=['group/level1'])
def test_Function(self):
"""Tests inventory on function"""
self._makeMovement(quantity=100, destination_function='function/function1')
......@@ -631,7 +631,7 @@ class TestInventory(InventoryAPITestCase):
# ... is equivalent to node_category
self.assertInventoryEquals(total_quantity,
node_category=category.getRelativeUrl())
@expectedFailure
def test_DoubleCategoryMembershipSectionCategory(self):
"""Tests inventory on section category, when the section is twice member\
......@@ -655,7 +655,7 @@ class TestInventory(InventoryAPITestCase):
self.assertInventoryEquals(100,
section_category_strict_membership=['group/level1/level2'])
self.assertInventoryEquals(100, section_uid=self.section.getUid())
def testPrecision(self):
# getInventory supports a precision= argument to specify the precision to
# round
......@@ -670,7 +670,7 @@ class TestInventory(InventoryAPITestCase):
getInventoryAssetPrice(precision=3,
node_uid=self.node.getUid()),
places=3)
def testPrecisionAndFloatRoundingIssues(self):
# sum([0.1] * 10) != 1.0 but this is not a problem here
getInventoryAssetPrice = self.getSimulationTool().getInventoryAssetPrice
......@@ -680,7 +680,7 @@ class TestInventory(InventoryAPITestCase):
self.assertInventoryEquals(0, precision=2, node_uid=self.node.getUid())
self.assertEquals(0, getInventoryAssetPrice(precision=2,
node_uid=self.node.getUid()))
def test_OmitInputOmitOutput(self):
self._makeMovement(quantity=1, price=1)
self._makeMovement(quantity=-1, price=1)
......@@ -699,7 +699,7 @@ class TestInventory(InventoryAPITestCase):
self._makeMovement(quantity=2, price=1, source_section_value=None)
self.assertInventoryEquals(-3, node_uid=self.node.getUid(), omit_input=1)
self.assertInventoryEquals(3, node_uid=self.node.getUid(), omit_output=1)
def test_OmitInputOmitOutputWithDifferentSections(self):
self._makeMovement(quantity=2, price=1)
self._makeMovement(quantity=-3, price=1,
......@@ -716,7 +716,7 @@ class TestInventory(InventoryAPITestCase):
self.assertInventoryEquals(0, node_uid=self.node.getUid(),
section_uid=self.other_section.getUid(),
omit_output=1)
def test_OmitInputOmitOutputWithDifferentPayment(self):
# simple case
self._makeMovement(quantity=2, price=1,
......@@ -753,7 +753,7 @@ class TestInventory(InventoryAPITestCase):
# omit_output & omit_input return nothing in that case
self.assertInventoryEquals(0, node_uid=self.node.getUid(),
omit_input=1, omit_output=1)
def test_OmitInputOmitOutputWithDifferentPaymentSameNodeSameSection(self):
self._makeMovement(quantity=2, price=1,
source_value=self.node,
......@@ -788,7 +788,7 @@ class TestInventory(InventoryAPITestCase):
def test_TimeZone(self):
"""
Check that getInventory support DateTime parameter with
Check that getInventory support DateTime parameter with
timezone
"""
date_gmt_1 = DateTime('2005/12/01 GMT+9')
......@@ -808,7 +808,7 @@ class TestInventoryList(InventoryAPITestCase):
"""Tests getInventoryList methods.
"""
def test_ReturnedTypeIsList(self):
"""Inventory List returns a sequence object"""
"""Inventory List returns a sequence object"""
getInventoryList = self.getSimulationTool().getInventoryList
inventory_list = getInventoryList()
self.assertEquals(str(inventory_list.__class__),
......@@ -1033,7 +1033,7 @@ class TestInventoryList(InventoryAPITestCase):
self._makeMovement(quantity=7, use='use2')
self._makeMovement(quantity=4, use='use2')
# note that grouping by related key only make sense if you group by strict
# memebership related keys
# membership related keys
inventory_list = getInventoryList(node_uid=(self.node.getUid(),
self.other_node.getUid()),
group_by=('strict_use_uid', ))
......@@ -1042,7 +1042,7 @@ class TestInventoryList(InventoryAPITestCase):
if r.getObject().getUse() == 'use1'][0].inventory, 5)
self.assertEquals([r for r in inventory_list
if r.getObject().getUse() == 'use2'][0].inventory, 11)
# in such case, it's interesting to pass a select expression, to be have on
# brain the information of which category is used
inventory_list = getInventoryList(node_uid=(self.node.getUid(),
......@@ -1131,7 +1131,7 @@ class TestInventoryList(InventoryAPITestCase):
self.assertEquals(0, len(getInventoryList(node_uid=self.node.getUid(),
omit_input=1,
omit_output=1)))
def test_OmitAssetIncreaseDecrease(self):
getInventoryList = self.getSimulationTool().getInventoryList
m1 = self._makeMovement(quantity=1, price=1)
......@@ -1241,7 +1241,7 @@ class TestInventoryList(InventoryAPITestCase):
inventory_list = method(node_uid=node_uid)
self.assertEquals(len(inventory_list), line)
if quantity is not None:
self.assertEquals(sum([x.total_quantity for x in inventory_list]),
self.assertEquals(sum([x.total_quantity for x in inventory_list]),
quantity)
makeMovement(quantity=1, simulation_state='ordered')
checkInventory(line=0, type='Current', destination=1)
......@@ -1396,7 +1396,7 @@ class TestMovementHistoryList(InventoryAPITestCase):
"""Tests Movement history list methods.
"""
def testReturnedTypeIsList(self):
"""Movement History List returns a sequence object"""
"""Movement History List returns a sequence object"""
getMovementHistoryList = self.getSimulationTool().getMovementHistoryList
mvt_history_list = getMovementHistoryList()
self.assertEquals(str(mvt_history_list.__class__),
......@@ -1415,17 +1415,17 @@ class TestMovementHistoryList(InventoryAPITestCase):
self.assertEquals(None, mvt_history_list[0].total_price)
def testMovementBothSides(self):
"""Movement History List returns movement from both sides"""
"""Movement History List returns movement from both sides"""
getMovementHistoryList = self.getSimulationTool().getMovementHistoryList
self._makeMovement(quantity=100)
# we don't filter, so we have the same movement from both sides.
self.assertEquals(2, len(getMovementHistoryList()))
def testBrainClass(self):
"""Movement History List uses InventoryListBrain for brains"""
"""Movement History List uses InventoryListBrain for brains"""
getMovementHistoryList = self.getSimulationTool().getMovementHistoryList
self._makeMovement(quantity=100)
# maybe this check is too low level (Shared/DC/ZRDB//Results.py, class r)
# maybe this check is too low level (Shared/DC/ZRDB//Results.py, class r)
r_bases = getMovementHistoryList()._class.__bases__
brain_class = r_bases[2].__name__
self.assertEquals('MovementHistoryListBrain', brain_class,
......@@ -1458,7 +1458,7 @@ class TestMovementHistoryList(InventoryAPITestCase):
self.assertEquals(100, mvt_history_list[0].total_quantity)
self.assertEquals(self.section.getRelativeUrl(),
mvt_history_list[0].section_relative_url)
def testMirrorSection(self):
getMovementHistoryList = self.getSimulationTool().getMovementHistoryList
mvt = self._makeMovement(quantity=100)
......@@ -1471,7 +1471,7 @@ class TestMovementHistoryList(InventoryAPITestCase):
mvt_history_list[0].section_relative_url)
self.assertEquals(self.mirror_node.getRelativeUrl(),
mvt_history_list[0].node_relative_url)
# if we look from the other side, everything is reverted
mvt_history_list = getMovementHistoryList(
section_uid = self.section.getUid())
......@@ -1481,7 +1481,7 @@ class TestMovementHistoryList(InventoryAPITestCase):
mvt_history_list[0].section_relative_url)
self.assertEquals(self.node.getRelativeUrl(),
mvt_history_list[0].node_relative_url)
def testDifferentDatesPerSection(self):
getMovementHistoryList = self.getSimulationTool().getMovementHistoryList
start_date = DateTime(2001, 1, 1)
......@@ -1495,7 +1495,7 @@ class TestMovementHistoryList(InventoryAPITestCase):
# stop_date is for destination
self.assertEquals(stop_date, getMovementHistoryList(
section_uid=self.section.getUid())[0].date)
def testNode(self):
getMovementHistoryList = self.getSimulationTool().getMovementHistoryList
mvt = self._makeMovement(quantity=100)
......@@ -1536,7 +1536,7 @@ class TestMovementHistoryList(InventoryAPITestCase):
# wrong value yields an empty list
self.assertEquals(0, len(getMovementHistoryList(
resource_uid = self.node.getUid())))
def testSectionCategory(self):
getMovementHistoryList = self.getSimulationTool().getMovementHistoryList
self.section.setGroup('level1/level2')
......@@ -1553,7 +1553,7 @@ class TestMovementHistoryList(InventoryAPITestCase):
section_category=section_category)
self.assertEquals(len(movement_history_list), 1)
self.assertEquals(movement_history_list[0].total_quantity, 100)
# again, bad category raises an exception
self.assertRaises(ValueError,
getMovementHistoryList,
......@@ -1562,7 +1562,7 @@ class TestMovementHistoryList(InventoryAPITestCase):
self.assertEquals(len(getMovementHistoryList(
section_category='group/level1',
ignored='argument')), 1)
@expectedFailure
def testDoubleSectionCategory(self):
# it is currently invalid to pass the same category twice to inventory API
......@@ -1632,7 +1632,7 @@ class TestMovementHistoryList(InventoryAPITestCase):
self._makeMovement(quantity=100,
start_date=date,
stop_date=date+1)
# from_date takes all movements >=
# from_date takes all movements >=
self.assertEquals(len(getMovementHistoryList(
from_date=DateTime(2006, 01, 03),
section_uid=self.section.getUid())), 2)
......@@ -1717,7 +1717,7 @@ class TestMovementHistoryList(InventoryAPITestCase):
from_date=DateTime(2006, 01, 02),
to_date=DateTime(2006, 01, 03),
section_uid=self.mirror_section.getUid())), 1)
def test_BrainDateTimeZone(self):
getMovementHistoryList = self.getSimulationTool().getMovementHistoryList
......@@ -1765,7 +1765,7 @@ class TestMovementHistoryList(InventoryAPITestCase):
self._makeMovement(quantity=100,
start_date=date - 1,
stop_date=date)
movement_date_list = [ x.date for x in getMovementHistoryList(
section_uid=self.section.getUid(),
sort_on=(('stock.date', 'ascending'),)) ]
......@@ -1787,7 +1787,7 @@ class TestMovementHistoryList(InventoryAPITestCase):
getMovementHistoryList = self.getSimulationTool().getMovementHistoryList
self._makeMovement(quantity=1, title='First')
self._makeMovement(quantity=2, title='Second')
self.assertEquals(['First', 'Second'], [ x.getObject().getTitle() for x in
getMovementHistoryList(section_uid=self.section.getUid(),
sort_on=(('title', 'ascending'),)) ])
......@@ -1801,7 +1801,7 @@ class TestMovementHistoryList(InventoryAPITestCase):
self._makeMovement(quantity=1)
self.assertEquals(3, len(getMovementHistoryList(limit=3)))
self.assertEquals(4, len(getMovementHistoryList(limit=(1, 4))))
def test_SimulationState(self):
getMovementHistoryList = self.getSimulationTool().getMovementHistoryList
self._makeMovement(quantity=2, simulation_state="confirmed")
......@@ -1812,7 +1812,7 @@ class TestMovementHistoryList(InventoryAPITestCase):
section_uid=self.section.getUid())
self.assertEquals(len(movement_history_list), 1)
self.assertEquals(movement_history_list[0].total_quantity, 2)
movement_history_list = getMovementHistoryList(
simulation_state=["confirmed", "planned"],
section_uid=self.section.getUid())
......@@ -1827,7 +1827,7 @@ class TestMovementHistoryList(InventoryAPITestCase):
movement_history_list = getMovementHistoryList(
section_uid=self.section.getUid())
self.assertEquals(2, len(movement_history_list))
def test_OmitSimulation(self):
"""Test omit_simulation argument to getMovementHistoryList.
"""
......@@ -1876,7 +1876,7 @@ class TestMovementHistoryList(InventoryAPITestCase):
self.assertEquals(running_total_quantity, brain.running_total_quantity)
self.assertEquals(date, brain.date)
self.assertEquals(quantity, brain.quantity)
def test_RunningTotalPrice(self):
"""Test that a running_total_price attribute is set on brains
"""
......@@ -1923,7 +1923,7 @@ class TestMovementHistoryList(InventoryAPITestCase):
self.assertEquals(running_total_quantity, brain.running_total_quantity)
running_total_price += quantity * quantity # we've set price=quantity
self.assertEquals(running_total_price, brain.running_total_price)
def testRunningQuantityWithQuantity0(self):
# a 0 quantity should not be a problem for running total price
getMovementHistoryList = self.getSimulationTool().getMovementHistoryList
......@@ -1951,7 +1951,7 @@ class TestMovementHistoryList(InventoryAPITestCase):
stop_date=date+1,
source_value=self.node,
destination_value=self.node )
mvt_history_list = getMovementHistoryList(
node_uid=self.node.getUid(),)
self.assertEquals(2, len(mvt_history_list))
......@@ -1967,7 +1967,7 @@ class TestMovementHistoryList(InventoryAPITestCase):
node_uid=self.node.getUid(),)
self.assertEquals(2, len(mvt_history_list))
self.assertEquals(0, sum([r.total_quantity for r in mvt_history_list]))
def testSameNodeSameDatesSameSections(self):
getMovementHistoryList = self.getSimulationTool().getMovementHistoryList
mvt = self._makeMovement( quantity=2,
......@@ -1985,7 +1985,7 @@ class TestMovementHistoryList(InventoryAPITestCase):
section_uid=self.section.getUid())
self.assertEquals(2, len(mvt_history_list))
self.assertEquals(0, sum([r.total_quantity for r in mvt_history_list]))
def testPrecision(self):
# getMovementHistoryList supports a precision= argument to specify the
# precision to round
......@@ -1999,7 +1999,7 @@ class TestMovementHistoryList(InventoryAPITestCase):
self.assertEquals(0.12, mvt_history_list[0].running_total_price)
self.assertEquals(0.12, mvt_history_list[0].total_quantity)
self.assertEquals(0.12, mvt_history_list[0].total_price)
mvt_history_list = getMovementHistoryList(
precision=3,
node_uid=self.node.getUid())
......@@ -2046,7 +2046,7 @@ class TestMovementHistoryList(InventoryAPITestCase):
node_uid=self.node.getUid(),
omit_input=1,
omit_output=1)))
def test_OmitAssetIncreaseDecrease(self):
getMovementHistoryList = self.getSimulationTool().getMovementHistoryList
m1 = self._makeMovement(quantity=1, price=-1)
......@@ -2268,7 +2268,7 @@ class TestTrackingList(InventoryAPITestCase):
item_uid = self.item.getUid()
other_item_uid = self.other_item.getUid()
node_uid = self.node.getUid()
self.assertEquals(len(getTrackingList(node_uid=node_uid,
self.assertEquals(len(getTrackingList(node_uid=node_uid,
at_date=start_date)),0)
makeMovement(aggregate=self.item)
result = getTrackingList(node_uid=node_uid,at_date=start_date)
......@@ -2292,7 +2292,7 @@ class TestTrackingList(InventoryAPITestCase):
item_uid = self.item.getUid()
other_item_uid = self.other_item.getUid()
node_uid = self.node.getUid()
self.assertEquals(len(getTrackingList(node_uid=node_uid,
self.assertEquals(len(getTrackingList(node_uid=node_uid,
at_date=start_date)),0)
makeMovement(aggregate_list=[self.item.getRelativeUrl(),
self.other_item.getRelativeUrl()])
......@@ -2351,133 +2351,62 @@ class TestTrackingList(InventoryAPITestCase):
'%s=now - %i, aggregate should be at node %i but is at node %i' % \
(param_id, now - date, node_uid_to_node_number[location_uid], node_uid_to_node_number[uid_list[0]]))
class TestInventoryDocument(InventoryAPITestCase):
""" Test impact of creating full inventories of stock points on inventory
lookup. This is an optimisation to regular inventory system to avoid
reading all stock entries since a node/section/payment is used when
gathering its amounts of resources.
class TestInventoryCacheTable(InventoryAPITestCase):
""" Test impact of creating cache entries into inventory_cache table
This is an optimisation on stock results to avoid reading all stock entries
for the same request.
Cache description:
A cache entry is defined by:
- a request identifier (implementation detail: MD5 of query source code)
- the date of first possible stock row *excluded* from cache entry (even if
there is no row at that exact date)
- cache data (implementation detail: pickle of a dict containing selected
properties of a query result)
Cache filling:
Cache might be filled by any request with either an at_date or a to_date,
and without a from_date. Cache entry date is chosen as "{at,to}_date -
cache_lag / 2".
No cache entry is created if a cache entry newer than "{at,to}_date -
cache_lag" exists.
Cache flush:
Cache is flushed by any modification (actually triggered on indexation or
de-indexation) on a stock-indexed document (aka movement). Only cache
entries with a date greater than any modified stock line are dropped.
"""
def _createAutomaticInventoryAtDate(self, date, override_inventory=None,
full_inventory=False):
"""
getInventoryList is tested to work in another unit test.
If full_inventory is false, only inventoriate the first resource
found.
"""
self.tic() # Tic so that grabbed inventory is up to date.
getInventoryList = self.getSimulationTool().getInventoryList
portal = self.getPortal()
inventory_module = portal.getDefaultModule(portal_type='Inventory')
inventory = inventory_module.newContent(portal_type='Inventory')
inventory.edit(destination_value=self.node,
destination_section_value=self.section,
start_date=date,
full_inventory=full_inventory)
inventory_list = getInventoryList(node_uid=self.node.getUid(),
at_date=date,
omit_output=1)
if full_inventory:
inventory_list = [inventory_list[0]]
# TODO: Define a second resource which will only be present in full
# inventories. This will allow testing getInventoryList.
#else:
# inventory_list.append({'resource_relative_url': '','total_quantity': 50,'variation_text': ''})
for inventory_line in inventory_list:
line = inventory.newContent(portal_type='Inventory Line')
if override_inventory is None:
total_quantity = inventory_line['total_quantity']
else:
total_quantity = override_inventory
line.edit(resource=inventory_line['resource_relative_url'],
inventory=total_quantity,
variation_text=inventory_line['variation_text'])
# TODO: pass more properties through from calculated inventory to
# inventory lines if needed.
inventory.deliver()
return inventory
def _populateInventoryModule(self):
"""
Create 3 inventories:
Type Deviation Date (see stepCreateInitialMovements)
- partial 1000
- full 10000
- full 100000
"""
self.BASE_QUANTITY = BASE_QUANTITY = 1
# TODO: It would be better to strip numbers below seconds instead of below
# days.
self.MAX_DATE = MAX_DATE = DateTime(DateTime().Date()) - 1
self.DUPLICATE_INVENTORY_DATE = MAX_DATE - 8 # Newest
self.INVENTORY_DATE_3 = INVENTORY_DATE_3 = MAX_DATE - 10 # Newest
def afterSetUp(self):
InventoryAPITestCase.afterSetUp(self)
self.CACHE_LAG = cache_lag = self.getSimulationTool().getInventoryCacheLag()
min_lag = cache_lag / 2
self.NOW = now = DateTime(DateTime().strftime("%Y-%m-%d %H:%M:%S UTC"))
self.CACHE_DATE = cache_date = now - min_lag
self.LAST_CACHED_MOVEMENT_DATE = last_cached_movement_date = \
cache_date - MYSQL_MIN_DATETIME_RESOLUTION
# First movement, won't be into cache
self.INVENTORY_DATE_3 = INVENTORY_DATE_3 = now - 10
self.INVENTORY_QUANTITY_3 = INVENTORY_QUANTITY_3 = 100000
self.INVENTORY_DATE_2 = INVENTORY_DATE_2 = INVENTORY_DATE_3 - 10
# Second movement, won't be into cache, just at the limit of cache min_lag
self.INVENTORY_DATE_2 = INVENTORY_DATE_2 = cache_date
self.INVENTORY_QUANTITY_2 = INVENTORY_QUANTITY_2 = 10000
self.INVENTORY_DATE_1 = INVENTORY_DATE_1 = INVENTORY_DATE_2 - 10 # Oldest
# Next will be stored as cache result after first getInventory
self.INVENTORY_DATE_1 = INVENTORY_DATE_1 = last_cached_movement_date
self.INVENTORY_QUANTITY_1 = INVENTORY_QUANTITY_1 = 1000
# "actual" quantities are the quantities which will end up in the stock
# table.
self.ACTUAL_INVENTORY_QUANTITY_1 = INVENTORY_QUANTITY_1 - \
BASE_QUANTITY
self.ACTUAL_INVENTORY_QUANTITY_2 = INVENTORY_QUANTITY_2 - \
(self.INVENTORY_QUANTITY_1 + BASE_QUANTITY)
self.ACTUAL_INVENTORY_QUANTITY_3 = INVENTORY_QUANTITY_3 - \
(self.INVENTORY_QUANTITY_2 + BASE_QUANTITY)
self.movement_uid_list = movement_uid_list = []
# Initial movement of 1
movement = self._makeMovement(quantity=BASE_QUANTITY,
start_date=INVENTORY_DATE_1 - 1,
simulation_state='delivered')
movement_uid_list.append(movement.getUid())
# First (partial) inventory of 1 000
partial_inventory = self._createAutomaticInventoryAtDate(
date=INVENTORY_DATE_1, override_inventory=INVENTORY_QUANTITY_1)
# Second movement of 1
movement = self._makeMovement(quantity=BASE_QUANTITY,
start_date=INVENTORY_DATE_2 - 1,
simulation_state='delivered')
movement_uid_list.append(movement.getUid())
# Second (full) inventory of 10 000
self._createAutomaticInventoryAtDate(date=INVENTORY_DATE_2,
override_inventory=INVENTORY_QUANTITY_2,
full_inventory=True)
# Third movement of 1
movement = self._makeMovement(quantity=BASE_QUANTITY,
start_date=INVENTORY_DATE_3 - 1,
simulation_state='delivered')
movement_uid_list.append(movement.getUid())
# Third (full) inventory of 100 000
self._createAutomaticInventoryAtDate(date=INVENTORY_DATE_3,
override_inventory=INVENTORY_QUANTITY_3,
full_inventory=True)
# Fourth movement of 1
movement = self._makeMovement(quantity=BASE_QUANTITY,
start_date=INVENTORY_DATE_3 + 1,
simulation_state='delivered')
movement_uid_list.append(movement.getUid())
self.tic()
manage_test = self.getPortal().erp5_sql_transactionless_connection.manage_test
def executeSQL(query):
manage_test("BEGIN\x00%s\x00COMMIT" % (query, ))
# Make stock table inconsistent with inventory_stock to make sure
# inventory_stock is actually tested.
executeSQL("UPDATE stock SET quantity=quantity*2 WHERE uid IN (%s)" %
(', '.join([str(x) for x in movement_uid_list]), ))
self.BASE_QUANTITY *= 2
# Make inventory_stock table inconsistent with stock to make sure
# inventory_stock is actually not used when checking that partial
# inventory is not taken into account.
executeSQL("UPDATE inventory_stock SET quantity=quantity*2 WHERE "\
"uid IN (%s)" % (', '.join([str(x.getUid()) for x in \
partial_inventory.objectValues()]),
))
def afterSetUp(self):
InventoryAPITestCase.afterSetUp(self)
self._populateInventoryModule()
# Create movements
self._makeMovement(
quantity=INVENTORY_QUANTITY_1,
start_date=INVENTORY_DATE_1,
simulation_state='delivered',
)
self._makeMovement(
quantity=INVENTORY_QUANTITY_2,
start_date=INVENTORY_DATE_2,
simulation_state='delivered',
)
self._makeMovement(
quantity=INVENTORY_QUANTITY_3,
start_date=INVENTORY_DATE_3,
simulation_state='delivered',
)
simulation_tool = self.getSimulationTool()
self.getInventory = simulation_tool.getInventory
self.getInventoryList = simulation_tool.getInventoryList
......@@ -2537,586 +2466,458 @@ class TestInventoryDocument(InventoryAPITestCase):
# Leads to rasing exception instead of calling self.assert[...] method.
if not success:
if ordered_check:
raise AssertionError, 'Line %r do not match %r' % \
raise AssertionError, 'Line %r\ndo not match\n %r' % \
(inventory_list[inventory_position],
criterion_dict)
else:
raise AssertionError, 'No line in %r match %r' % \
raise AssertionError, 'No line in %r\n match\n %r' % \
(inventory_list, criterion_dict)
# Check all expected lines have been found.
self.assertFalse(inventory_list)
def _fillCache(self, inventory_list=False):
"""
Calling getInventoryXXX will fill the cache for us
"""
if inventory_list:
result_list = self.getInventoryList(node_uid=self.node_uid, to_date=self.NOW)
result = 0
for line in result_list:
result += line.quantity
else:
result = self.getInventory(node_uid=self.node_uid, to_date=self.NOW)
self.assertEqual(result, self.INVENTORY_QUANTITY_1 + \
self.INVENTORY_QUANTITY_2 + self.INVENTORY_QUANTITY_3)
return result
def doubleStockValue(self):
"""
Make stock table inconsistent so that we can check that
optimisation is well used
"""
# in the test, this will always be the date at which an entry is put into
# inventory_cache
self.getPortalObject().erp5_sql_transactionless_connection.manage_test(
"BEGIN\0"
"UPDATE stock SET quantity=quantity*2 WHERE date < '%s'\0"
"COMMIT" % ((self.CACHE_DATE).ISO(), ))
self.commit()
def assertInventoryEquals(self, value, inventory_kw):
"""
Check that optimised getInventory call is equal to given value
and that unoptimised call is *not* equal to thi value.
"""
# Make stock table inconsistent to be sure it uses cache
self.doubleStockValue()
# Check it use cache
self.assertEquals(value, self.getInventory(**inventory_kw))
self.assertNotEquals(value,
self.getInventory(optimisation__=False,
**inventory_kw))
def setUpDefaultInventoryCalculationList(self):
createZODBPythonScript(self.portal.portal_skins.custom,
'Inventory_getDefaultInventoryCalculationList', '',
'''return ({
'inventory_params':{
'section_uid':context.getDestinationSectionUid(),
'node_uid':context.getDestinationUid(),
'group_by_variation':1,
'group_by_resource':1},
'list_method':'getMovementList',
'first_level':({'key':'resource_relative_url',
'getter':'getResource',
'setter':('appendToCategoryList', 'resource')},
{'key':'variation_text',
'getter':'getVariationText',
'setter':'splitAndExtendToCategoryList'},
),
},)
''')
self.commit()
def clearAllInventoryAndSetUpTwoInventory(self):
self.portal.inventory_module.manage_delObjects(list(self.portal.inventory_module.objectIds()))
self.folder.manage_delObjects(list(self.folder.objectIds()))
self.resource.setProductLine('level1/level2')
self.other_resource.setProductLine('anotherlevel')
self.section.setGroup('level1/level2')
self.other_section.setGroup('anotherlevel')
self.node.setRegion('level1')
self.other_node.setGroup('anotherlevel')
full_inventory = self.portal.inventory_module.newContent(portal_type='Inventory')
full_inventory.edit(destination_section_value=self.section,
destination_value=self.node,
full_inventory=1,
start_date=DateTime('2012/05/18 00:00:00 GMT+9'))
line = full_inventory.newContent(portal_type='Inventory Line')
line.setResourceValue(self.resource)
line.setQuantity(1)
line = full_inventory.newContent(portal_type='Inventory Line')
line.setResourceValue(self.other_resource)
line.setQuantity(10)
full_inventory.deliver()
full_inventory = self.portal.inventory_module.newContent(portal_type='Inventory')
full_inventory.edit(destination_section_value=self.other_section,
destination_value=self.node,
full_inventory=1,
start_date=DateTime('2012/05/18 00:00:00 GMT+9'))
line = full_inventory.newContent(portal_type='Inventory Line')
line.setResourceValue(self.resource)
line.setQuantity(100)
line = full_inventory.newContent(portal_type='Inventory Line')
line.setResourceValue(self.other_resource)
line.setQuantity(1000)
full_inventory.deliver()
full_inventory = self.portal.inventory_module.newContent(portal_type='Inventory')
full_inventory.edit(destination_section_value=self.section,
destination_value=self.other_node,
full_inventory=1,
start_date=DateTime('2012/05/18 00:00:00 GMT+9'))
line = full_inventory.newContent(portal_type='Inventory Line')
line.setResourceValue(self.resource)
line.setQuantity(10000)
line = full_inventory.newContent(portal_type='Inventory Line')
line.setResourceValue(self.other_resource)
line.setQuantity(100000)
full_inventory.deliver()
full_inventory = self.portal.inventory_module.newContent(portal_type='Inventory')
full_inventory.edit(destination_section_value=self.other_section,
destination_value=self.other_node,
full_inventory=1,
start_date=DateTime('2012/05/18 00:00:00 GMT+9'))
line = full_inventory.newContent(portal_type='Inventory Line')
line.setResourceValue(self.resource)
line.setQuantity(1000000)
line = full_inventory.newContent(portal_type='Inventory Line')
line.setResourceValue(self.other_resource)
line.setQuantity(10000000)
full_inventory.deliver()
self.commit()
self.tic()
def test_01_CurrentInventoryWithFullInventory(self):
def test_01_CurrentInventory(self):
"""
Check that inventory optimisation is executed when querying current
amount (there is a usable full inventory which is the latest).
Check that optimisation is executed when querying current
amount
"""
self.assertInventoryEquals(value=self.INVENTORY_QUANTITY_3 + \
self.BASE_QUANTITY,
inventory_kw={'node_uid': self.node_uid})
def test_02_InventoryAtLatestFullInventoryDate(self):
self.assertInventoryEquals(
self._fillCache(),
inventory_kw={
'node_uid': self.node_uid,
'to_date': self.NOW,
}
)
def test_02_InventoryAtCacheDate(self):
"""
Check that inventory optimisation is executed when querying an amount
at the exact time of latest usable full inventory.
Check that optimisation is executed when querying an amount
at the exact time of the cache of result
"""
self.assertInventoryEquals(value=self.INVENTORY_QUANTITY_3,
inventory_kw={'node_uid': self.node_uid,
'at_date': self.INVENTORY_DATE_3})
def test_03_InventoryAtEarlierFullInventoryDate(self):
self._fillCache()
# We got results from cache + results from stock
self.assertInventoryEquals(
self.INVENTORY_QUANTITY_2 + self.INVENTORY_QUANTITY_1,
inventory_kw={
'node_uid': self.node_uid,
'at_date': self.CACHE_DATE,
},
)
def test_03_InventoryToCacheDate(self):
"""
Check that inventory optimisation is executed when querying past
amount (there is a usable full inventory which is not the latest).
Check that optimisation is executed when querying an amount
that will only take into account cache data.
"""
self.assertInventoryEquals(value=self.INVENTORY_QUANTITY_2 + \
self.BASE_QUANTITY,
inventory_kw={'node_uid': self.node_uid,
'at_date': self.INVENTORY_DATE_3 - \
1})
def test_04_InventoryBeforeFullInventoryAfterPartialInventory(self):
self._fillCache()
# We got only results from cache
self.assertInventoryEquals(
self.INVENTORY_QUANTITY_1,
inventory_kw={
'node_uid': self.node_uid,
'to_date': self.CACHE_DATE,
},
)
def test_04_InventoryList(self):
"""
Check that optimisation is not executed when querying past amount
with no usable full inventory.
If optimisation was executed,
self.INVENTORY_QUANTITY_1 * 2 + self.BASE_QUANTITY * 2
would be found.
Check that optimisation is executed when querying current
amount list
"""
self.assertEquals(self.ACTUAL_INVENTORY_QUANTITY_1 + \
self.BASE_QUANTITY * 2,
self.getInventory(node_uid=self.node_uid,
at_date=self.INVENTORY_DATE_2 - 1))
def test_05_InventoryListWithFullInventory(self):
self._fillCache(True)
# Check we got all results
self._checkInventoryList(
self.getInventoryList(node_uid=self.node_uid, to_date=self.NOW),
[{
'date': self.INVENTORY_DATE_3,
'inventory': self.INVENTORY_QUANTITY_3,
'node_uid': self.node_uid,
}, {
'date': self.INVENTORY_DATE_2,
'inventory': self.INVENTORY_QUANTITY_2,
'node_uid': self.node_uid,
}, {
'date': self.INVENTORY_DATE_1,
'inventory': self.INVENTORY_QUANTITY_1,
'node_uid': self.node_uid,
}],
)
def test_05_InventoryListAtCacheDate(self):
"""
Check that inventory optimisation is executed when querying current
amount list (there is a usable full inventory which is the latest).
Check that optimisation is executed when querying an amount list
at the exact time of the cache of result
"""
inventory = self.getInventoryList(node_uid=self.node_uid)
reference_inventory = [
{'date': self.INVENTORY_DATE_3,
'inventory': self.INVENTORY_QUANTITY_3,
'node_uid': self.node_uid},
{'date': self.INVENTORY_DATE_3 + 1,
'inventory': self.BASE_QUANTITY,
'node_uid': self.node_uid}
]
self._checkInventoryList(inventory, reference_inventory)
def test_06_InventoryListAtLatestFullInventoryDate(self):
self._fillCache(True)
# We got results from cache + results from stock
self._checkInventoryList(
self.getInventoryList(node_uid=self.node_uid, at_date=self.CACHE_DATE),
[{
'date': self.INVENTORY_DATE_1,
'inventory': self.INVENTORY_QUANTITY_1,
'node_uid': self.node_uid,
}, {
'date': self.INVENTORY_DATE_2,
'inventory': self.INVENTORY_QUANTITY_2,
'node_uid': self.node_uid,
}],
)
def test_06_InventoryListToCacheDate(self):
"""
Check that inventory optimisation is executed when querying past
amount list (there is a usable full inventory which is not the latest).
Check that optimisation is executed when querying an amount list
that will only take into account cache data.
"""
inventory = self.getInventoryList(node_uid=self.node_uid,
at_date=self.INVENTORY_DATE_3)
reference_inventory = [
{'date': self.INVENTORY_DATE_3,
'inventory': self.INVENTORY_QUANTITY_3,
'node_uid': self.node_uid}
]
self._checkInventoryList(inventory, reference_inventory)
def test_07_InventoryListAtEarlierFullInventoryDate(self):
"""
Check that inventory optimisation is executed when querying past
amount list (there is a usable full inventory which is not the latest).
"""
inventory = self.getInventoryList(node_uid=self.node_uid,
at_date=self.INVENTORY_DATE_3 - 1)
reference_inventory = [
{'date': self.INVENTORY_DATE_2,
'inventory': self.INVENTORY_QUANTITY_2,
'node_uid': self.node_uid},
{'date': self.INVENTORY_DATE_3 - 1,
'inventory': self.BASE_QUANTITY,
'node_uid': self.node_uid}
]
self._checkInventoryList(inventory, reference_inventory)
def test_08_InventoryListBeforeFullInventoryAfterPartialInventory(self):
"""
Check that optimisation is not executed when querying past amount list
with no usable full inventory.
"""
inventory = self.getInventoryList(node_uid=self.node_uid,
at_date=self.INVENTORY_DATE_2 - 1)
reference_inventory = [
{'date': self.INVENTORY_DATE_1 - 1,
'inventory': self.BASE_QUANTITY,
'node_uid': self.node_uid},
{'date': self.INVENTORY_DATE_1,
'inventory': self.ACTUAL_INVENTORY_QUANTITY_1,
'node_uid': self.node_uid},
{'date': self.INVENTORY_DATE_2 - 1,
'inventory': self.BASE_QUANTITY,
'node_uid': self.node_uid}
]
self._checkInventoryList(inventory, reference_inventory)
def test_09_InventoryListGroupedByResource(self):
self._fillCache(True)
# We got only results from cache
self._checkInventoryList(
self.getInventoryList(node_uid=self.node_uid, to_date=self.CACHE_DATE),
[{
'date': self.INVENTORY_DATE_1,
'inventory': self.INVENTORY_QUANTITY_1,
'node_uid': self.node_uid,
}],
)
def test_07_InventoryListGroupedByResource(self):
"""
Group inventory list by resource explicitely, used inventory is the
latest.
Check that optimisation is executed when grouping inventory list
by resource explicitely
"""
inventory = self.getInventoryList(node_uid=self.node_uid,
group_by_resource=1)
reference_inventory = [
{'inventory': self.INVENTORY_QUANTITY_3 + self.BASE_QUANTITY,
'resource_uid': self.resource.getUid(),
'node_uid': self.node_uid}
]
self._checkInventoryList(inventory, reference_inventory)
def test_10_InventoryListGroupedByResourceBeforeLatestFullInventoryDate(self):
inventory_kw = {
'node_uid': self.node_uid,
'to_date': self.NOW,
'group_by_resource': 1,
}
# Fill cache
self.getInventoryList(**inventory_kw)
# Check we got all results
self._checkInventoryList(
self.getInventoryList(**inventory_kw),
[{
'inventory': self.INVENTORY_QUANTITY_3 + self.INVENTORY_QUANTITY_2 + \
self.INVENTORY_QUANTITY_1,
'resource_uid': self.resource.getUid(),
'node_uid': self.node_uid,
}],
)
def test_08_InventoryListGroupedByResourceAtCacheDate(self):
"""
Group inventory list by resource explicitely, used inventory is not the
latest.
Check that optimisation is executed when grouping inventory list
by resource explicitely
"""
inventory = self.getInventoryList(node_uid=self.node_uid,
group_by_resource=1,
at_date=self.INVENTORY_DATE_3 - 1)
reference_inventory = [
{'inventory': self.INVENTORY_QUANTITY_2 + self.BASE_QUANTITY,
'resource_uid': self.resource.getUid(),
'node_uid': self.node_uid}
]
self._checkInventoryList(inventory, reference_inventory)
def test_11_InventoryListAroundLatestInventoryDate(self):
# Fill cache
self.getInventoryList(node_uid=self.node_uid, to_date=self.NOW,
group_by_resource=1)
# We got results from cache + results from stock
self._checkInventoryList(
self.getInventoryList(node_uid=self.node_uid, at_date=self.CACHE_DATE,
group_by_resource=1),
[{
'inventory': self.INVENTORY_QUANTITY_2 + self.INVENTORY_QUANTITY_1,
'resource_uid': self.resource.getUid(),
'node_uid': self.node_uid,
}],
)
def test_09_InventoryListGroupByResourceToCacheDate(self):
"""
Test getInventoryList with a min and a max date around latest full
inventory. A full inventory is used and is not the latest.
Check that optimisation is executed when grouping inventory list
by resource explicitely
"""
inventory = self.getInventoryList(node_uid=self.node_uid,
from_date=self.INVENTORY_DATE_3 - 1,
at_date=self.INVENTORY_DATE_3 + 1)
reference_inventory = [
{'inventory': self.BASE_QUANTITY,
'resource_uid': self.resource.getUid(),
'node_uid': self.node_uid,
'date': self.INVENTORY_DATE_3 - 1},
{'inventory': self.ACTUAL_INVENTORY_QUANTITY_3,
'resource_uid': self.resource.getUid(),
'node_uid': self.node_uid,
'date': self.INVENTORY_DATE_3},
{'inventory': self.BASE_QUANTITY,
'resource_uid': self.resource.getUid(),
'node_uid': self.node_uid,
'date': self.INVENTORY_DATE_3 + 1}
]
self._checkInventoryList(inventory, reference_inventory)
def test_12_InventoryListWithOrderByDate(self):
self.getInventoryList(node_uid=self.node_uid, to_date=self.NOW, group_by_resource=1)
# We got only results from cache
self._checkInventoryList(
self.getInventoryList(node_uid=self.node_uid, to_date=self.CACHE_DATE,
group_by_resource=1),
[{
'inventory': self.INVENTORY_QUANTITY_1,
'resource_uid': self.resource.getUid(),
'node_uid': self.node_uid,
}],
)
def test_10_InventoryListWithOrderByDate(self):
"""
Test order_by is preserved by optimisation on date column.
Also sort on total_quantity column because there are inventory lines
which are on the same date but with distinct quantities.
"""
inventory = self.getInventoryList(node_uid=self.node_uid,
from_date=self.INVENTORY_DATE_3 - 1,
at_date=self.INVENTORY_DATE_3 + 1,
sort_on=(('date', 'ASC'),
('total_quantity', 'DESC')))
reference_inventory = [
{'inventory': self.BASE_QUANTITY,
'resource_uid': self.resource.getUid(),
'node_uid': self.node_uid,
'date': self.INVENTORY_DATE_3 - 1},
{'inventory': self.ACTUAL_INVENTORY_QUANTITY_3,
'resource_uid': self.resource.getUid(),
'node_uid': self.node_uid,
'date': self.INVENTORY_DATE_3},
{'inventory': self.BASE_QUANTITY,
'resource_uid': self.resource.getUid(),
'node_uid': self.node_uid,
'date': self.INVENTORY_DATE_3 + 1}
]
self._checkInventoryList(inventory, reference_inventory,
ordered_check=True)
inventory = self.getInventoryList(node_uid=self.node_uid,
from_date=self.INVENTORY_DATE_3 - 1,
at_date=self.INVENTORY_DATE_3 + 1,
sort_on=(('date', 'DESC'),
('total_quantity', 'ASC')))
inventory_kw={
'node_uid': self.node_uid,
'to_date': self.NOW,
}
sort_on = (('date', 'ASC'), ('total_quantity', 'DESC'))
reversed_sort_on = (('date', 'DESC'), ('total_quantity', 'ASC'))
reference_inventory = [{
'inventory': self.INVENTORY_QUANTITY_1,
'resource_uid': self.resource.getUid(),
'node_uid': self.node_uid,
'date': self.INVENTORY_DATE_1,
}, {
'inventory': self.INVENTORY_QUANTITY_2,
'resource_uid': self.resource.getUid(),
'node_uid': self.node_uid,
'date': self.INVENTORY_DATE_2
}, {
'inventory': self.INVENTORY_QUANTITY_3,
'resource_uid': self.resource.getUid(),
'node_uid': self.node_uid,
'date': self.INVENTORY_DATE_3,
}]
# Fill cache
self.getInventoryList(sort_on=sort_on, **inventory_kw)
# Check it in fist order
self._checkInventoryList(
self.getInventoryList(sort_on=sort_on, **inventory_kw),
reference_inventory,
ordered_check=True,
)
# Check it in reverse order
reference_inventory.reverse()
self._checkInventoryList(inventory, reference_inventory,
ordered_check=True)
self._checkInventoryList(
self.getInventoryList(sort_on=reversed_sort_on, **inventory_kw),
reference_inventory,
ordered_check=True,
)
def test_13_InventoryAfterModificationInPast(self):
def test_11_InventoryWithFromDate(self):
"""
Test inventory after adding a new movement in past and reindex all inventory
Test that getInventory called with from date does not generate cache entry
"""
movement = self._makeMovement(quantity=self.BASE_QUANTITY*2,
start_date=self.INVENTORY_DATE_3 - 2,
simulation_state='delivered')
# reindex inventory module, although we modified table by hand
# everything must be consistent after reindexation
inventory_module = self.getPortal().getDefaultModule(portal_type='Inventory')
inventory_module.recursiveReindexObject()
self.tic()
inventory_kw={'node_uid': self.node_uid,
'at_date': self.INVENTORY_DATE_3}
value=self.INVENTORY_QUANTITY_3
# use optimisation
inventory_kw = {
'node_uid': self.node_uid,
'to_date': self.NOW,
'from_date': self.NOW - self.CACHE_LAG - 10,
}
# Double stock value and check that we got same result
# with and without optimisation
value = self.INVENTORY_QUANTITY_3 + self.INVENTORY_QUANTITY_2 + \
2 * self.INVENTORY_QUANTITY_1
self.doubleStockValue()
self.assertEquals(value, self.getInventory(**inventory_kw))
# without optimisation
self.assertEquals(value,
self.getInventory(optimisation__=False,
**inventory_kw))
self.getInventory(optimisation__=False,
**inventory_kw))
def test_14_TwoInventoryWithSameDateAndResourceAndNode(self):
def test_12_CheckCacheFlush(self):
"""
It makes no sense to validate two inventories with same date,
same resource, and same node. The calculation of inventories
will not work in such case. So here we test that a constraint
does not allow such things
Test the cache is flushed when indexing a movement into stock
"""
portal = self.getPortal()
self._addPropertySheet('Inventory', 'InventoryConstraint')
try:
inventory_module = portal.getDefaultModule(portal_type='Inventory')
inventory = inventory_module.newContent(portal_type='Inventory')
date = self.DUPLICATE_INVENTORY_DATE
inventory.edit(destination_value=self.node,
destination_section_value=self.section,
start_date=date)
inventory_line = inventory.newContent(
resource_value = self.resource,
quantity = 1)
self.workflow_tool = portal.portal_workflow
workflow_id = 'inventory_workflow'
transition_id = 'deliver_action'
workflow_id= 'inventory_workflow'
self.workflow_tool.doActionFor(inventory, transition_id,
wf_id=workflow_id)
self.assertEquals('delivered', inventory.getSimulationState())
self.tic()
# We should detect the previous inventory and fails
new_inventory = inventory.Base_createCloneDocument(batch_mode=1)
self.assertRaises(ValidationFailed, self.workflow_tool.doActionFor,
new_inventory, transition_id, wf_id=workflow_id)
workflow_history = self.workflow_tool.getInfoFor(ob=new_inventory,
name='history', wf_id=workflow_id)
workflow_error_message = str(workflow_history[-1]['error_message'])
self.assertTrue(len(workflow_error_message))
self.assertTrue(len([x for x in workflow_error_message \
if x.find('There is already an inventory')]))
# Add a case in order to check a bug when the other inventory at the
# same date does not change stock values
new_inventory = inventory.Base_createCloneDocument(batch_mode=1)
new_inventory.setStartDate(self.DUPLICATE_INVENTORY_DATE + 1)
self.workflow_tool.doActionFor(new_inventory, transition_id,
wf_id=workflow_id)
self.assertEquals('delivered', new_inventory.getSimulationState())
self.tic()
new_inventory = new_inventory.Base_createCloneDocument(batch_mode=1)
self.assertRaises(ValidationFailed, self.workflow_tool.doActionFor,
new_inventory, transition_id, wf_id=workflow_id)
workflow_history = self.workflow_tool.getInfoFor(ob=new_inventory,
name='history', wf_id=workflow_id)
workflow_error_message = str(workflow_history[-1]['error_message'])
self.assertTrue(len(workflow_error_message))
self.assertTrue(len([x for x in workflow_error_message \
if x.find('There is already an inventory')]))
finally:
# remove all property sheets we added to type informations
ttool = self.getTypesTool()
for ti_name, psheet_list in self._added_property_sheets.iteritems():
ti = ttool.getTypeInfo(ti_name)
property_sheet_set = set(ti.getTypePropertySheetList())
property_sheet_set.difference_update(psheet_list)
ti._setTypePropertySheetList(list(property_sheet_set))
self.commit()
_aq_reset()
def test_15_InventoryAfterModificationInFuture(self):
inventory_kw = {
'node_uid': self.node_uid,
'to_date': self.NOW,
}
# Fill cache and make stock inconsistent
value = self.getInventory(**inventory_kw)
self.doubleStockValue()
# Create a movement after CACHE DATE
# Cache is cleared for all entry > movement date
# as at a cache date D, it contains results from stock
# for all line < D
INVENTORY_QUANTITY_4 = 5000
INVENTORY_DATE_4 = self.CACHE_DATE
movement = self._makeMovement(
quantity=INVENTORY_QUANTITY_4,
start_date=INVENTORY_DATE_4,
simulation_state='delivered',
)
self.tic()
# Optimisation must still be used
self.assertEquals(
value + INVENTORY_QUANTITY_4,
self.getInventory(**inventory_kw),
)
# Edit start date so that cache table is cleared
movement.edit(start_date=self.LAST_CACHED_MOVEMENT_DATE)
self.tic()
self.assertEquals(
value + INVENTORY_QUANTITY_4 + self.INVENTORY_QUANTITY_1,
self.getInventory(**inventory_kw),
)
self.doubleStockValue()
# Cache hit again
self.assertEquals(
value + INVENTORY_QUANTITY_4 + self.INVENTORY_QUANTITY_1,
self.getInventory(**inventory_kw),
)
# Delete movement, so it gets unindexed
self.folder.manage_delObjects(ids=[movement.getId(), ])
self.tic()
self.assertEquals(
value + 3 * self.INVENTORY_QUANTITY_1,
self.getInventory(**inventory_kw),
)
self.doubleStockValue()
# Cache hit again
self.assertEquals(
value + 3 * self.INVENTORY_QUANTITY_1,
self.getInventory(**inventory_kw),
)
def test_13_CacheCreatedFromCache(self):
"""
Test inventory after adding a new movement in future
Test that a new cache entry is created from previous cache entry if it exists
"""
movement = self._makeMovement(quantity=self.BASE_QUANTITY*2,
start_date=self.INVENTORY_DATE_3 + 2,
simulation_state='delivered')
self.tic()
# Create an old movement
INVENTORY_QUANTITY_4 = 100
INVENTORY_DATE_4 = self.NOW - 3 * self.CACHE_LAG
movement = self._makeMovement(quantity=INVENTORY_QUANTITY_4,
start_date=INVENTORY_DATE_4,
simulation_state='delivered')
# Get inventory in past so that cache is filled
inventory_kw={'node_uid': self.node_uid,
"to_date" : self.NOW - 2 * self.CACHE_LAG,}
value = self.getInventory(**inventory_kw)
self.assertInventoryEquals(value, inventory_kw)
# Now compute wanted value manually as we screwed the stock table
# As we double every movement < CACHE_LAG/2, inventory_1 is doubled
# like inventory_4, but inventory_4 must be retrieved from cache with
# its initial value
wanted_value = 2 * self.INVENTORY_QUANTITY_1 + self.INVENTORY_QUANTITY_2 + \
self.INVENTORY_QUANTITY_3 + INVENTORY_QUANTITY_4
inventory_kw={'node_uid': self.node_uid,
"to_date" : self.NOW,}
value = self.getInventory(**inventory_kw)
self.assertEqual(value, wanted_value)
# Make sure it has filled a new cache
self.assertInventoryEquals(wanted_value, inventory_kw)
def getCurrentInventoryPathList(resource, **kw):
# the brain is not a zsqlbrain instance here, so it does not
# have getPath().
return [x.path for x in resource.getCurrentInventoryList(**kw)]
# use optimisation
self.assertEquals(True,movement.getPath() in
[x.path for x in self.resource.getInventoryList(
mirror_uid=self.mirror_node.getUid())])
# without optimisation
self.assertEquals(True,movement.getPath() in
[x.path for x in self.resource.getInventoryList(
optimisation__=False,
mirror_uid=self.mirror_node.getUid())])
@expectedFailure
def test_MultipleSectionAndFullInventory(self):
"""Make sure that getInventoryList works in the situation which
two sections use the same node and one section has full inventory for
the node.
def test_14_CacheTableCreatedOnGetInventoryCall(self):
"""
# In this test we do not need doucments made by afterSetUp.
self.portal.inventory_module.manage_delObjects(list(self.portal.inventory_module.objectIds()))
self.folder.manage_delObjects(list(self.folder.objectIds()))
self.commit()
Check that getInventory does not fail it cache table does not exist
and that it create the table and add an entry in it
"""
self.portal.SimulationTool_zDropInventoryCache()
# Make sure it is dropped
self.assertRaises(ProgrammingError,
self.portal.SimulationTool_zTrimInventoryCacheFromDateOnCatalog,
date=DateTime())
# Check that src__ call still works
inventory_kw={
'node_uid': self.node_uid,
'to_date': self.NOW,
}
self.getInventory(src__=1, **inventory_kw)
# Table is still not created
self.assertRaises(ProgrammingError,
self.portal.SimulationTool_zTrimInventoryCacheFromDateOnCatalog,
date=DateTime())
# This call should not fail
# It will create table, fill it and check optimisation is used
self.assertInventoryEquals(
self._fillCache(),
inventory_kw=inventory_kw,
)
def test_15_CacheTableCreatedOnIndexation(self):
"""
Check that getInventory does not fail it cache table does not exist
and that it create the table and add an entry in it
"""
self.portal.SimulationTool_zDropInventoryCache()
# Make sure it is dropped
self.assertRaises(ProgrammingError,
self.portal.SimulationTool_zTrimInventoryCacheFromDateOnCatalog,
date=DateTime())
# Create a new movement, indexation should not fail
INVENTORY_QUANTITY_4 = 5000
INVENTORY_DATE_4 = self.CACHE_DATE
movement = self._makeMovement(
quantity=INVENTORY_QUANTITY_4,
start_date=INVENTORY_DATE_4,
simulation_state='delivered',
)
self.tic()
createZODBPythonScript(self.portal.portal_skins.custom,
'Inventory_getDefaultInventoryCalculationList', '',
'''return ({
'inventory_params':{
'section_uid':context.getDestinationSectionUid(),
'node_uid':context.getDestinationUid(),
'group_by_variation':1,
'group_by_resource':1},
'list_method':'getMovementList',
'first_level':({'key':'resource_relative_url',
'getter':'getResource',
'setter':('appendToCategoryList', 'resource')},
{'key':'variation_text',
'getter':'getVariationText',
'setter':'splitAndExtendToCategoryList'},
),
},)
''')
self.commit()
getCurrentInventoryList = self.getSimulationTool().getCurrentInventoryList
# Add movements for section
self._makeMovement(source_section_value=None,
source_value=None,
destination_section_value=self.section,
destination_value=self.node,
start_date=DateTime('2012/07/18 00:00:00 GMT+9'),
simulation_state='delivered',
resource_value=self.resource,
quantity=1)
self._makeMovement(source_section_value=None,
source_value=None,
destination_section_value=self.section,
destination_value=self.node,
start_date=DateTime('2012/07/21 00:00:00 GMT+9'),
simulation_state='delivered',
resource_value=self.resource,
quantity=2)
# Add movemnets for other section
self._makeMovement(source_section_value=None,
source_value=None,
destination_section_value=self.other_section,
destination_value=self.node,
start_date=DateTime('2012/07/19 00:00:00 GMT+9'),
simulation_state='delivered',
resource_value=self.resource,
quantity=3)
self._makeMovement(source_section_value=None,
source_value=None,
destination_section_value=self.other_section,
destination_value=self.node,
start_date=DateTime('2012/07/20 00:00:00 GMT+9'),
simulation_state='delivered',
resource_value=self.resource,
quantity=4)
self.commit()
# Optimisation must then be used
inventory_kw={
'node_uid': self.node_uid,
'to_date': self.NOW,
}
value = self.getInventory(**inventory_kw)
self.assertInventoryEquals(
value,
inventory_kw=inventory_kw,
)
def test_16_CacheTableCreatedOnUnindexation(self):
"""
Check that getInventory does not fail it cache table does not exist
and that it create the table and add an entry in it
"""
# Create a new movement
INVENTORY_QUANTITY_4 = 5000
INVENTORY_DATE_4 = self.CACHE_DATE
movement = self._makeMovement(
quantity=INVENTORY_QUANTITY_4,
start_date=INVENTORY_DATE_4,
simulation_state='delivered',
)
self.tic()
# Check inventory
result = {}
for brain in getCurrentInventoryList(node_uid=self.node.getUid(),
group_by_resource=1,
group_by_node=1,
group_by_section=1):
key = (brain.section_uid, brain.node_uid, brain.resource_uid)
if not key in result:
result[key] = 0
result[key] = result[key] + brain.inventory
self.assertEqual(result,
{(self.section.getUid(), self.node.getUid(), self.resource.getUid()):3,
(self.other_section.getUid(), self.node.getUid(), self.resource.getUid()):7})
# Add full inventory for section, not for other section
full_inventory1 = self.portal.inventory_module.newContent(portal_type='Inventory')
full_inventory1.edit(destination_section_value=self.section,
destination_value=self.node,
full_inventory=1,
start_date=DateTime('2012/07/20 00:00:00 GMT+9'))
line = full_inventory1.newContent(portal_type='Inventory Line')
line.setResourceValue(self.resource)
line.setQuantity(100)
full_inventory1.deliver()
self.commit()
# Check it also works on unindexation
self.portal.SimulationTool_zDropInventoryCache()
# Make sure it is dropped
self.assertRaises(ProgrammingError,
self.portal.SimulationTool_zTrimInventoryCacheFromDateOnCatalog,
date=DateTime())
# Delete movement
self.folder.manage_delObjects(ids=[movement.getId(), ])
self.tic()
# This call must not fail as table has been created
self.portal.SimulationTool_zTrimInventoryCacheFromDateOnCatalog(date=DateTime())
# Check inventory again. This time, full inventory should change
# section's inventory. It should not change other section's inventory.
result = {}
for brain in getCurrentInventoryList(node_uid=self.node.getUid(),
group_by_resource=1,
group_by_node=1,
group_by_section=1):
key = (brain.section_uid, brain.node_uid, brain.resource_uid)
if not key in result:
result[key] = 0
result[key] = result[key] + brain.inventory
self.assertEqual(result,
{(self.section.getUid(), self.node.getUid(), self.resource.getUid()):102,
(self.other_section.getUid(), self.node.getUid(), self.resource.getUid()):7})
# This call should not fail
# It will create table, fill it and check optimisation is used
self.assertInventoryEquals(
self._fillCache(),
inventory_kw={
'node_uid': self.node_uid,
'to_date': self.NOW,
}
)
@expectedFailure
def test_ResourceCategory(self):
"""Make sure that resource category works when full inventory exists."""
# In this test we do not need doucments made by afterSetUp.
self.setUpDefaultInventoryCalculationList()
self.clearAllInventoryAndSetUpTwoInventory()
self.assertEquals(1,
self.getInventory(section_uid=self.section.getUid(),
node_uid=self.node.getUid(),
resource_category='product_line/level1',
optimisation__=False))
self.assertEquals(1,
self.getInventory(section_uid=self.section.getUid(),
node_uid=self.node.getUid(),
resource_category='product_line/level1',
optimisation__=True))
@expectedFailure
def test_SectionCategory(self):
"""Make sure that section category works when full inventory exists."""
# In this test we do not need doucments made by afterSetUp.
self.clearAllInventoryAndSetUpTwoInventory()
self.assertEquals(11,
self.getInventory(node_uid=self.node.getUid(),
section_category='group/level1/level2',
optimisation__=False))
self.assertEquals(11,
self.getInventory(node_uid=self.node.getUid(),
section_category='group/level1/level2',
optimisation__=True))
@expectedFailure
def test_NodeCategory(self):
# In this test we do not need doucments made by afterSetUp.
self.clearAllInventoryAndSetUpTwoInventory()
self.assertEquals(11,
self.getInventory(section_uid=self.section.getUid(),
node_category='region/level1',
optimisation__=False))
self.assertEquals(11,
self.getInventory(section_uid=self.section.getUid(),
node_category='region/level1',
optimisation__=True))
class BaseTestUnitConversion(InventoryAPITestCase):
QUANTITY_UNIT_DICT = {}
......@@ -3543,10 +3344,10 @@ def test_suite():
suite.addTest(unittest.makeSuite(TestInventoryStat))
suite.addTest(unittest.makeSuite(TestNextNegativeInventoryDate))
suite.addTest(unittest.makeSuite(TestTrackingList))
suite.addTest(unittest.makeSuite(TestInventoryDocument))
suite.addTest(unittest.makeSuite(TestInventoryCacheTable))
suite.addTest(unittest.makeSuite(TestUnitConversion))
suite.addTest(unittest.makeSuite(TestUnitConversionDefinition))
suite.addTest(unittest.makeSuite(TestUnitConversionBackwardCompatibility))
return suite
# vim: foldmethod=marker
......@@ -1554,8 +1554,12 @@ class TestERP5Catalog(ERP5TypeTestCase, LogInterceptor):
# Do a hot reindex in the reverse way, but this time a more
# complicated hot reindex
portal_catalog.manage_hotReindexAll(self.new_catalog_id,
self.original_catalog_id)
portal_catalog.manage_hotReindexAll(
source_sql_catalog_id=self.new_catalog_id,
destination_sql_catalog_id=self.original_catalog_id,
source_sql_connection_id_list=destination_sql_connection_id_list,
destination_sql_connection_id_list=source_sql_connection_id_list,
update_destination_sql_catalog=True)
self.commit()
self.assertEquals(portal_catalog.getHotReindexingState(),
HOT_REINDEXING_RECORDING_STATE)
......@@ -1735,6 +1739,17 @@ class TestERP5Catalog(ERP5TypeTestCase, LogInterceptor):
# Check more cached methods of SQLCatalog by building SQLQuery
query = portal_catalog.getSQLCatalog().buildQuery(kw={'dummy.dummy_title': 'Foo'})
self.assertTrue(query.query_list)
# We need to reset SQL connections in skin folder's zsql methods
sql_connection_id_dict = {}
for destination_sql_connection_id, source_sql_connection_id in \
zip(destination_sql_connection_id_list,
source_sql_connection_id_list):
if source_sql_connection_id != destination_sql_connection_id:
sql_connection_id_dict[destination_sql_connection_id] = \
source_sql_connection_id
portal_catalog.changeSQLConnectionIds(
folder=portal.portal_skins,
sql_connection_id_dict = sql_connection_id_dict)
def test_47_Unrestricted(self, quiet=quiet, run=run_all_test):
"""test unrestricted search/count results.
......
......@@ -6,7 +6,9 @@
from Products.PythonScripts.Utility import allow_class
from Products.CMFCore.WorkflowCore import WorkflowException
from MySQLdb import ProgrammingError
allow_class(ProgrammingError)
class DeferredCatalogError(Exception):
......@@ -53,3 +55,4 @@ allow_class(WorkflowException)
allow_class(UnsupportedWorkflowMethod)
allow_class(TransformationRuleError)
allow_class(SimulationError)
......@@ -14,7 +14,6 @@ import os
import random
import re
import socket
import shutil
import sys
import time
import traceback
......@@ -26,7 +25,6 @@ from glob import glob
from hashlib import md5
from warnings import warn
from ExtensionClass import pmc_init_of
from ZTUtils import make_query
from DateTime import DateTime
# XXX make sure that get_request works.
......@@ -148,17 +146,17 @@ def _getConnectionStringDict():
def _getConversionServerDict():
""" Returns a dict with hostname and port for Conversion Server (Oood)
"""
conversion_server_hostname = os.environ.get('conversion_server_hostname',
conversion_server_hostname = os.environ.get('conversion_server_hostname',
'localhost')
conversion_server_port = os.environ.get('conversion_server_port',
'8008')
return dict(hostname=conversion_server_hostname,
return dict(hostname=conversion_server_hostname,
port=int(conversion_server_port))
def _getVolatileMemcachedServerDict():
"""Returns a dict with hostname and port for volatile memcached Server
"""
hostname = os.environ.get('volatile_memcached_server_hostname',
hostname = os.environ.get('volatile_memcached_server_hostname',
'localhost')
port = os.environ.get('volatile_memcached_server_port', '11211')
return dict(hostname=hostname, port=port)
......@@ -166,7 +164,7 @@ def _getVolatileMemcachedServerDict():
def _getPersistentMemcachedServerDict():
"""Returns a dict with hostname and port for persistent memcached Server
"""
hostname = os.environ.get('persistent_memcached_server_hostname',
hostname = os.environ.get('persistent_memcached_server_hostname',
'localhost')
port = os.environ.get('persistent_memcached_server_port', '12121')
return dict(hostname=hostname, port=port)
......@@ -478,7 +476,7 @@ class ERP5TypeTestCaseMixin(ProcessingNodeTestCase, PortalTestCase):
if searchable_business_template_list is None:
searchable_business_template_list = ["erp5_base"]
# Assume that the public official repository is a valid repository
# Assume that the public official repository is a valid repository
public_bt5_repository_list = ['http://www.erp5.org/dists/snapshot/bt5/']
template_list = []
......@@ -620,7 +618,7 @@ class ERP5TypeTestCaseMixin(ProcessingNodeTestCase, PortalTestCase):
from AccessControl.SecurityManagement import getSecurityManager
from AccessControl.SecurityManagement import setSecurityManager
# Save current security manager
sm = getSecurityManager()
......@@ -703,7 +701,7 @@ class ERP5TypeCommandLineTestCase(ERP5TypeTestCaseMixin):
def _app(self):
'''Opens a ZODB connection and returns the app object.
We override it to patch HTTP_ACCEPT_CHARSET into REQUEST to get the zpt
unicode conflict resolver to work properly'''
app = PortalTestCase._app(self)
......@@ -818,7 +816,8 @@ class ERP5TypeCommandLineTestCase(ERP5TypeTestCaseMixin):
if update_business_templates and erp5_load_data_fs:
update_only = os.environ.get('update_only', None)
template_list = (erp5_catalog_storage, 'erp5_property_sheets',
'erp5_core', 'erp5_xhtml_style') + tuple(template_list)
'erp5_core', 'erp5_xhtml_style') \
+ tuple(template_list)
# Update only specified business templates, regular expression
# can be used.
if update_only is not None:
......@@ -837,6 +836,10 @@ class ERP5TypeCommandLineTestCase(ERP5TypeTestCaseMixin):
light_install = self.enableLightInstall()
create_activities = self.enableActivityTool()
hot_reindexing = self.enableHotReindexing()
# We want to always have optimisation available
if "erp5_stock_cache" not in template_list:
template_list = list(template_list)
template_list.append("erp5_stock_cache")
self.setUpERP5Site(business_template_list=template_list,
light_install=light_install,
create_activities=create_activities,
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment