From e5852ab668cc51565bb7fc906e77527965727ebb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Fri, 27 Jul 2018 17:07:31 +0200 Subject: [PATCH] Add count wizard on inventory issue7515 review49461002 --- CHANGELOG | 1 + __init__.py | 9 +- doc/index.rst | 13 +- inventory.py | 155 ++++++++++++++++++++++- inventory.xml | 27 +++- tests/scenario_stock_inventory_count.rst | 120 ++++++++++++++++++ tests/test_stock.py | 5 + view/inventory_count_quantity_form.xml | 15 +++ view/inventory_count_search_form.xml | 7 + view/inventory_form.xml | 6 +- 10 files changed, 344 insertions(+), 14 deletions(-) create mode 100644 tests/scenario_stock_inventory_count.rst create mode 100644 view/inventory_count_quantity_form.xml create mode 100644 view/inventory_count_search_form.xml 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 @@ + + +
+