diff --git a/CHANGELOG b/CHANGELOG
index 40fb1da1ec..bf7852846c 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,3 +1,4 @@
+* Add count wizard on inventory
* Remove support for Python 2.7
* Prevent delete lines if inventory is not draft or canceled
* Manage same input and storage location in customer return shipment
diff --git a/__init__.py b/__init__.py
index 0173b4b6c4..1aee34cdf4 100644
--- a/__init__.py
+++ b/__init__.py
@@ -7,7 +7,7 @@ from .shipment import *
from .period import *
from .move import *
from .product import *
-from .inventory import *
+from . import inventory
from .configuration import *
from . import party
@@ -36,8 +36,10 @@ def register():
ProductByLocationContext,
ProductQuantitiesByWarehouse,
ProductQuantitiesByWarehouseContext,
- Inventory,
- InventoryLine,
+ inventory.Inventory,
+ inventory.InventoryLine,
+ inventory.CountSearch,
+ inventory.CountQuantity,
Configuration,
ConfigurationSequence,
ConfigurationLocation,
@@ -47,6 +49,7 @@ def register():
AssignShipmentInternal,
AssignShipmentInReturn,
RecomputeCostPrice,
+ inventory.Count,
party.PartyReplace,
party.PartyErase,
module='stock', type_='wizard')
diff --git a/doc/index.rst b/doc/index.rst
index af6780fe0e..21b67a7076 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -261,11 +261,14 @@ Inventory
Inventories allow to control and update stock levels. They are mainly
composed of two locations (a Storage location and a Lost And Found
-location), and a list of inventory lines. A button allow to
-auto-complete inventory lines with respect to the expected quantities
-for each product in the location. Inventory lines consist of: a
-product and it's default unit of measure, an expected quantity and the
-real quantity (the real products on the shelves).
+location), and a list of inventory lines.
+Inventory lines consist of a product and it's default unit of measure, an
+expected quantity and the real quantity (the real products on the shelves).
+
+A button allows to auto-complete inventory lines with respect to the expected
+quantities for each product in the location.
+Another button allows to launch a wizard to count products by adding the
+quantity to the existing matching line.
When the inventory is confirmed, moves are created to balance expected
quantities and real ones.
diff --git a/inventory.py b/inventory.py
index 2b7d10c6ab..ef1a1dd6df 100644
--- a/inventory.py
+++ b/inventory.py
@@ -3,11 +3,13 @@
from sql import Null
from trytond.model import Workflow, Model, ModelView, ModelSQL, fields, Check
-from trytond.pyson import Eval, Bool
+from trytond.pyson import Eval, Bool, If
from trytond.transaction import Transaction
from trytond.pool import Pool
+from trytond.wizard import Wizard, StateView, StateTransition, Button
-__all__ = ['Inventory', 'InventoryLine']
+__all__ = ['Inventory', 'InventoryLine',
+ 'Count', 'CountSearch', 'CountQuantity']
STATES = {
'readonly': Eval('state') != 'draft',
@@ -86,6 +88,10 @@ class Inventory(Workflow, ModelSQL, ModelView):
'readonly': Eval('state') != 'draft',
'depends': ['state'],
},
+ 'count': {
+ 'readonly': Eval('state') != 'draft',
+ 'depends': ['state'],
+ },
})
@classmethod
@@ -274,6 +280,11 @@ class Inventory(Workflow, ModelSQL, ModelView):
if to_write:
Line.write(*to_write)
+ @classmethod
+ @ModelView.button_action('stock.wizard_inventory_count')
+ def count(cls, inventories):
+ cls.complete_lines(inventories)
+
class InventoryLine(ModelSQL, ModelView):
'Stock Inventory Line'
@@ -476,3 +487,143 @@ class InventoryLine(ModelSQL, ModelView):
'line': line.rec_name,
})
super(InventoryLine, cls).delete(lines)
+
+
+class Count(Wizard):
+ "Stock Inventory Count"
+ __name__ = 'stock.inventory.count'
+ start_state = 'search'
+
+ search = StateView(
+ 'stock.inventory.count.search',
+ 'stock.inventory_count_search_view_form', [
+ Button("End", 'end', 'tryton-cancel'),
+ Button("Select", 'quantity', 'tryton-go-next', default=True),
+ ])
+ quantity = StateView(
+ 'stock.inventory.count.quantity',
+ 'stock.inventory_count_quantity_view_form', [
+ Button("Cancel", 'search', 'tryton-cancel'),
+ Button("Add", 'add', 'tryton-ok', default=True),
+ ])
+ add = StateTransition()
+
+ @classmethod
+ def __setup__(cls):
+ super(Count, cls).__setup__()
+ cls._error_messages.update({
+ 'create_line': "No existing line found for %(search)s.",
+ })
+
+ def default_quantity(self, fields):
+ pool = Pool()
+ Inventory = pool.get('stock.inventory')
+ InventoryLine = pool.get('stock.inventory.line')
+ context = Transaction().context
+ inventory = Inventory(context['active_id'])
+ values = {}
+ lines = InventoryLine.search(self.get_line_domain(inventory), limit=1)
+ if not lines:
+ warning_name = '%s.%s.count_create' % (
+ inventory, self.search.search)
+ self.raise_user_warning(warning_name, 'create_line', {
+ 'search': self.search.search.rec_name,
+ })
+ line, = InventoryLine.create([self.get_line_values(inventory)])
+ else:
+ line, = lines
+ values['line'] = line.id
+ values['product'] = line.product.id
+ values['uom'] = line.uom.id
+ values['unit_digits'] = line.unit_digits
+ if line.uom.rounding == 1:
+ values['quantity_added'] = 1
+ return values
+
+ def get_line_domain(self, inventory):
+ pool = Pool()
+ Product = pool.get('product.product')
+ domain = [
+ ('inventory', '=', inventory.id),
+ ]
+ if isinstance(self.search.search, Product):
+ domain.append(('product', '=', self.search.search.id))
+ return domain
+
+ def get_line_values(self, inventory):
+ pool = Pool()
+ Product = pool.get('product.product')
+ InventoryLine = pool.get('stock.inventory.line')
+ values = InventoryLine.create_values4complete(inventory, 0)
+ if isinstance(self.search.search, Product):
+ values['product'] = self.search.search.id
+ return values
+
+ def transition_add(self):
+ if self.quantity.line and self.quantity.quantity_added:
+ line = self.quantity.line
+ if line.quantity:
+ line.quantity += self.quantity.quantity_added
+ else:
+ line.quantity = self.quantity.quantity_added
+ line.save()
+ return 'search'
+
+
+class CountSearch(ModelView):
+ "Stock Inventory Count"
+ __name__ = 'stock.inventory.count.search'
+
+ search = fields.Reference(
+ "Search", [
+ ('product.product', "Product"),
+ ],
+ required=True,
+ domain=[If(Eval('search_model') == 'product.product',
+ [
+ ('type', '=', 'goods'),
+ ('consumable', '=', False),
+ ],
+ [])],
+ depends=['search_model'])
+ search_model = fields.Function(fields.Selection(
+ 'get_search_models', "Search Model"),
+ 'on_change_with_search_model')
+
+ @classmethod
+ def default_search(cls):
+ return 'product.product,-1'
+
+ @classmethod
+ def get_search_models(cls):
+ return cls.fields_get(['search'])['search']['selection']
+
+ @fields.depends('search')
+ def on_change_with_search_model(self, name=None):
+ if self.search:
+ return self.search.__name__
+
+
+class CountQuantity(ModelView):
+ "Stock Inventory Count"
+ __name__ = 'stock.inventory.count.quantity'
+
+ line = fields.Many2One(
+ 'stock.inventory.line', "Line", readonly=True, required=True)
+ product = fields.Many2One('product.product', "Product", readonly=True)
+ uom = fields.Many2One('product.uom', "UOM", readonly=True)
+ quantity_resulting = fields.Float(
+ "Resulting Quantity", digits=(16, Eval('unit_digits', 2)),
+ readonly=True, depends=['unit_digits'])
+
+ quantity_added = fields.Float(
+ "Added Quantity", digits=(16, Eval('unit_digits', 2)), required=True,
+ depends=['unit_digits'])
+
+ unit_digits = fields.Integer("Unit Digits", readonly=True)
+
+ @fields.depends('quantity_added', 'line')
+ def on_change_quantity_added(self):
+ if self.line:
+ self.quantity_resulting = (
+ (self.line.quantity or 0) + (self.quantity_added or 0))
diff --git a/inventory.xml b/inventory.xml
index 45ec2054c6..22c77275fb 100644
--- a/inventory.xml
+++ b/inventory.xml
@@ -116,11 +116,36 @@ this repository contains the full copyright notices and license terms. -->
complete_lines
- Complete Inventory
+ Complete
Add an inventory line for each missing products
+
+ count
+ Count
+ Launch the wizard to count products
+
+
+
+
+ Count
+ stock.inventory.count
+ stock.inventory
+
+
+
+ stock.inventory.count.search
+ form
+ inventory_count_search_form
+
+
+
+ stock.inventory.count.quantity
+ form
+ inventory_count_quantity_form
+
+
Inventory
stock.inventory
diff --git a/tests/scenario_stock_inventory_count.rst b/tests/scenario_stock_inventory_count.rst
new file mode 100644
index 0000000000..ca7287b5ef
--- /dev/null
+++ b/tests/scenario_stock_inventory_count.rst
@@ -0,0 +1,120 @@
+==============================
+Stock Inventory Count Scenario
+==============================
+
+Imports::
+
+ >>> import datetime
+ >>> from dateutil.relativedelta import relativedelta
+ >>> from decimal import Decimal
+ >>> from proteus import Model, Wizard
+ >>> from trytond.tests.tools import activate_modules
+ >>> from trytond.modules.company.tests.tools import create_company, \
+ ... get_company
+ >>> today = datetime.date.today()
+
+Install stock Module::
+
+ >>> config = activate_modules('stock')
+
+Create company::
+
+ >>> _ = create_company()
+ >>> company = get_company()
+
+Get stock locations::
+
+ >>> Location = Model.get('stock.location')
+ >>> supplier_loc, = Location.find([('code', '=', 'SUP')])
+ >>> storage_loc, = Location.find([('code', '=', 'STO')])
+ >>> customer_loc, = Location.find([('code', '=', 'CUS')])
+
+Create products::
+
+ >>> ProductUom = Model.get('product.uom')
+ >>> ProductTemplate = Model.get('product.template')
+
+ >>> unit, = ProductUom.find([('name', '=', 'Unit')])
+ >>> template = ProductTemplate()
+ >>> template.name = 'Product'
+ >>> template.default_uom = unit
+ >>> template.type = 'goods'
+ >>> template.list_price = Decimal('300')
+ >>> template.cost_price_method = 'average'
+ >>> product, = template.products
+ >>> product.cost_price = Decimal('80')
+ >>> template.save()
+ >>> product, = template.products
+
+ >>> kg, = ProductUom.find([('name', '=', 'Kilogram')])
+ >>> template2 = ProductTemplate()
+ >>> template2.name = 'Product'
+ >>> template2.default_uom = kg
+ >>> template2.type = 'goods'
+ >>> template2.list_price = Decimal('140')
+ >>> template2.cost_price_method = 'average'
+ >>> product2, = template2.products
+ >>> product2.cost_price = Decimal('60')
+ >>> template2.save()
+ >>> product2, = template2.products
+
+Create an inventory::
+
+ >>> Inventory = Model.get('stock.inventory')
+ >>> inventory = Inventory()
+ >>> inventory.location = storage_loc
+ >>> inventory.empty_quantity = 'keep'
+ >>> inventory.save()
+
+Count inventory::
+
+ >>> _ = inventory.click('count')
+ >>> count = Wizard('stock.inventory.count', [inventory])
+
+ >>> count.form.search = product
+
+ >>> count.execute('quantity') # doctest: +IGNORE_EXCEPTION_DETAIL
+ Traceback (most recent call last):
+ ...
+ UserWarning: ...
+
+ >>> Model.get('res.user.warning')(user=config.user,
+ ... name='stock.inventory,%s.product.product,%s.count_create' % (
+ ... inventory.id, product.id)).save()
+ >>> count.execute('quantity')
+ >>> count.form.quantity_added
+ 1
+ >>> count.form.quantity_resulting
+ 1
+ >>> count.execute('add')
+
+ >>> count.form.search = product
+ >>> count.execute('quantity')
+ >>> count.form.quantity_resulting
+ 2.0
+ >>> count.execute('add')
+
+ >>> count.form.search = product2
+ >>> Model.get('res.user.warning')(user=config.user,
+ ... name='stock.inventory,%s.product.product,%s.count_create' % (
+ ... inventory.id, product2.id)).save()
+ >>> count.execute('quantity')
+ >>> count.form.quantity_added
+ >>> count.form.quantity_resulting
+ >>> count.form.quantity_added = 10
+ >>> count.form.quantity_resulting
+ 10.0
+ >>> count.execute('add')
+
+ >>> count.execute('end')
+
+Check inventory::
+
+ >>> len(inventory.lines)
+ 2
+ >>> line1, = [l for l in inventory.lines if l.product == product]
+ >>> line1.quantity
+ 2.0
+ >>> line2, = [l for l in inventory.lines if l.product == product2]
+ >>> line2.quantity
+ 10.0
diff --git a/tests/test_stock.py b/tests/test_stock.py
index 2696ef713d..dc9e4e2f03 100644
--- a/tests/test_stock.py
+++ b/tests/test_stock.py
@@ -1296,6 +1296,11 @@ def suite():
tearDown=doctest_teardown, encoding='utf-8',
checker=doctest_checker,
optionflags=doctest.REPORT_ONLY_FIRST_FAILURE))
+ suite.addTests(doctest.DocFileSuite(
+ 'scenario_stock_inventory_count.rst',
+ tearDown=doctest_teardown, encoding='utf-8',
+ checker=doctest_checker,
+ optionflags=doctest.REPORT_ONLY_FIRST_FAILURE))
suite.addTests(doctest.DocFileSuite('scenario_stock_shipment_internal.rst',
tearDown=doctest_teardown, encoding='utf-8',
checker=doctest_checker,
diff --git a/view/inventory_count_quantity_form.xml b/view/inventory_count_quantity_form.xml
new file mode 100644
index 0000000000..db724c8002
--- /dev/null
+++ b/view/inventory_count_quantity_form.xml
@@ -0,0 +1,15 @@
+
+
+
diff --git a/view/inventory_count_search_form.xml b/view/inventory_count_search_form.xml
new file mode 100644
index 0000000000..7b24f5d291
--- /dev/null
+++ b/view/inventory_count_search_form.xml
@@ -0,0 +1,7 @@
+
+
+
diff --git a/view/inventory_form.xml b/view/inventory_form.xml
index bfbbda1674..eca14d3730 100644
--- a/view/inventory_form.xml
+++ b/view/inventory_form.xml
@@ -12,8 +12,6 @@ this repository contains the full copyright notices and license terms. -->
-
-
@@ -21,7 +19,9 @@ this repository contains the full copyright notices and license terms. -->
-
+
+
+