From 1b46c4653458b54beebc510cf41badfbb525ca88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Wed, 3 Feb 2021 21:19:06 +0100 Subject: [PATCH] Add wizard to add lots and sequence for lot number issue9800 review326511002 --- CHANGELOG | 2 + __init__.py | 9 + ir.xml | 19 ++ message.xml | 3 + product.py | 43 +++- product.xml | 6 + stock.py | 259 +++++++++++++++++++- stock.xml | 30 +++ tests/scenario_stock_lot_move_add.rst | 108 ++++++++ tests/scenario_stock_lot_number.rst | 51 ++++ tests/test_stock_lot.py | 8 + tryton.cfg | 1 + view/move_form.xml | 3 + view/move_tree.xml | 3 + view/product_configuration_form.xml | 9 + view/stock_move_add_lots_start_form.xml | 20 ++ view/stock_move_add_lots_start_lot_list.xml | 9 + view/template_form.xml | 2 + 18 files changed, 578 insertions(+), 7 deletions(-) create mode 100644 ir.xml create mode 100644 tests/scenario_stock_lot_move_add.rst create mode 100644 tests/scenario_stock_lot_number.rst create mode 100644 view/product_configuration_form.xml create mode 100644 view/stock_move_add_lots_start_form.xml create mode 100644 view/stock_move_add_lots_start_lot_list.xml diff --git a/CHANGELOG b/CHANGELOG index a96d721832..49a86c2014 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,5 @@ +* Add wizard to add lots +* Allow lot sequence number to be configured on product * Add lots_by_locations Model Version 5.8.0 - 2020-11-02 diff --git a/__init__.py b/__init__.py index 7053c9fb57..ab4244a25a 100644 --- a/__init__.py +++ b/__init__.py @@ -5,6 +5,10 @@ from trytond.pool import Pool from . import stock from . import product +from .stock import LotMixin + +__all__ = ['LotMixin', 'register'] + def register(): Pool.register( @@ -14,6 +18,8 @@ def register(): stock.LotByWarehouseContext, stock.Location, stock.Move, + stock.MoveAddLotsStart, + stock.MoveAddLotsStartLot, stock.ShipmentIn, stock.ShipmentOut, stock.ShipmentOutReturn, @@ -23,9 +29,12 @@ def register(): stock.Inventory, stock.InventoryLine, stock.InventoryCountSearch, + product.Configuration, + product.ConfigurationDefaultLotSequence, product.Template, product.Product, module='stock_lot', type_='model') Pool.register( + stock.MoveAddLots, stock.InventoryCount, module='stock_lot', type_='wizard') diff --git a/ir.xml b/ir.xml new file mode 100644 index 0000000000..7a2aa50218 --- /dev/null +++ b/ir.xml @@ -0,0 +1,19 @@ + + + + + + Stock Lot + stock.lot + + + + + + + + + + + diff --git a/message.xml b/message.xml index b7c42ac3cd..08b54d5eb7 100644 --- a/message.xml +++ b/message.xml @@ -12,5 +12,8 @@ this repository contains the full copyright notices and license terms. --> You must select a stock lot instead of a product for "%(product)s". + + The total quantity "%(lot_quantity)s" of the lots must be less than or equal to the quantity "%(move_quantity)s" of the move. + diff --git a/product.py b/product.py index e8a01ec771..7fcd92d476 100644 --- a/product.py +++ b/product.py @@ -3,12 +3,33 @@ from sql import Table from trytond.config import config -from trytond.model import fields +from trytond.model import ModelSQL, ValueMixin, fields from trytond.pyson import Eval -from trytond.pool import PoolMeta +from trytond.pool import PoolMeta, Pool from trytond.transaction import Transaction +class Configuration(metaclass=PoolMeta): + __name__ = 'product.configuration' + + default_lot_sequence = fields.MultiValue( + fields.Many2One( + 'ir.sequence', "Default Lot Sequence", + domain=[ + ('code', '=', 'stock.lot'), + ])) + + +class ConfigurationDefaultLotSequence(ModelSQL, ValueMixin): + "Product Configuration Default Lot Sequence" + __name__ = 'product.configuration.default_lot_sequence' + default_lot_sequence = fields.Many2One( + 'ir.sequence', "Default Lot Sequence", + domain=[ + ('code', '=', 'stock.lot'), + ]) + + class Template(metaclass=PoolMeta): __name__ = 'product.template' @@ -24,6 +45,16 @@ class Template(metaclass=PoolMeta): 'invisible': ~Eval('type').in_(['goods', 'assets']), }, depends=['type']) + lot_sequence = fields.Many2One( + 'ir.sequence', "Lot Sequence", + domain=[ + ('code', '=', 'stock.lot'), + ], + states={ + 'invisible': ~Eval('type').in_(['goods', 'assets']), + }, + depends=['type'], + help="The sequence used to automatically number lots.") @classmethod def __register__(cls, module): @@ -66,6 +97,14 @@ class Template(metaclass=PoolMeta): template_lot_type_table_name) table_h.drop_table('stock.lot.type', lot_type_table_name) + @classmethod + def default_lot_sequence(cls, **pattern): + pool = Pool() + Configuration = pool.get('product.configuration') + sequence = Configuration(1).get_multivalue( + 'default_lot_sequence', **pattern) + return sequence.id if sequence else None + class Product(metaclass=PoolMeta): __name__ = 'product.product' diff --git a/product.xml b/product.xml index 70184cd889..e36d47b573 100644 --- a/product.xml +++ b/product.xml @@ -3,6 +3,12 @@ this repository contains the full copyright notices and license terms. --> + + product.configuration + + product_configuration_form + + product.template diff --git a/stock.py b/stock.py index d611fc0c75..aa00a9cf23 100644 --- a/stock.py +++ b/stock.py @@ -1,18 +1,21 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. import datetime +from copy import copy from functools import wraps from sql import Column from trytond.i18n import gettext from trytond.model import Model, ModelView, ModelSQL, fields -from trytond.model.exceptions import AccessError, RequiredValidationError -from trytond.pyson import Eval +from trytond.model.exceptions import ( + AccessError, RequiredValidationError, ValidationError) +from trytond.pyson import Eval, Bool, Len from trytond.pool import Pool, PoolMeta from trytond.tools import grouped_slice from trytond.transaction import Transaction from trytond.modules.stock import StockMixin +from trytond.wizard import Wizard, StateView, StateTransition, Button def check_no_move(func): @@ -47,12 +50,29 @@ def check_no_move(func): return decorator -class Lot(ModelSQL, ModelView, StockMixin): +class LotMixin: + + number = fields.Char( + "Number", required=True, select=True, + states={ + 'required': ~Eval('has_sequence') | (Eval('id', -1) >= 0), + }, + depends=['has_sequence']) + product = fields.Many2One('product.product', 'Product', required=True) + has_sequence = fields.Function( + fields.Boolean("Has Sequence"), 'on_change_with_has_sequence') + + @fields.depends('product') + def on_change_with_has_sequence(self, name=None): + if self.product: + return bool(self.product.lot_sequence) + + +class Lot(ModelSQL, ModelView, LotMixin, StockMixin): "Stock Lot" __name__ = 'stock.lot' _rec_name = 'number' - number = fields.Char('Number', required=True, select=True) - product = fields.Many2One('product.product', 'Product', required=True) + quantity = fields.Function(fields.Float('Quantity'), 'get_quantity', searcher='search_quantity') forecast_quantity = fields.Function(fields.Float('Forecast Quantity'), @@ -93,6 +113,24 @@ class Lot(ModelSQL, ModelView, StockMixin): if self.product: return self.product.default_uom.digits + @classmethod + def create(cls, vlist): + vlist = [v.copy() for v in vlist] + for values in vlist: + if not values.get('number'): + values['number'] = cls._new_number(values) + return super().create(vlist) + + @classmethod + def _new_number(cls, values): + pool = Pool() + Product = pool.get('product.product') + Sequence = pool.get('ir.sequence') + if values.get('product'): + product = Product(values['product']) + if product.lot_sequence: + return Sequence.get_id(product.lot_sequence.id) + @classmethod @check_no_move def write(cls, *args): @@ -235,6 +273,22 @@ class Move(metaclass=PoolMeta): }, depends=['state', 'product']) + @classmethod + def __setup__(cls): + super().__setup__() + cls._buttons.update({ + 'add_lots_wizard': { + 'invisible': ~Eval('state').in_(['draft', 'assigned']), + 'readonly': Bool(Eval('lot')), + 'depends': ['lot', 'state'], + }, + }) + + @classmethod + @ModelView.button_action('stock_lot.wizard_move_add_lots') + def add_lots_wizard(cls, moves): + pass + def check_lot(self): "Check if lot is required" if (self.state == 'done' @@ -253,6 +307,201 @@ class Move(metaclass=PoolMeta): move.check_lot() +class MoveAddLots(Wizard): + "Add Lots" + __name__ = 'stock.move.add.lots' + start = StateView('stock.move.add.lots.start', + 'stock_lot.move_add_lots_start_view_form', [ + Button("Cancel", 'end', 'tryton-cancel'), + Button("Add", 'add', 'tryton-ok', default=True), + ]) + add = StateTransition() + + def default_start(self, fields): + default = {} + if 'product' in fields: + default['product'] = self.record.product.id + if 'quantity' in fields: + default['quantity'] = self.record.quantity + if 'unit' in fields: + default['unit'] = self.record.uom.id + return default + + def transition_add(self): + pool = Pool() + Lang = pool.get('ir.lang') + Lot = pool.get('stock.lot') + lang = Lang.get() + quantity_remaining = self.start.on_change_with_quantity_remaining() + if quantity_remaining < 0: + digits = self.record.uom.digits + move_quantity = self.record.quantity + lot_quantity = self.record.quantity - quantity_remaining + raise ValidationError(gettext( + 'stock_lot.msg_move_add_lot_quantity', + lot_quantity=lang.format('%.*f', (digits, lot_quantity)), + move_quantity=lang.format( + '%.*f', (digits, move_quantity)))) + lots = [] + for line in self.start.lots: + lot = line.get_lot(self.record) + lots.append(lot) + Lot.save(lots) + if quantity_remaining: + self.record.quantity = quantity_remaining + self.record.save() + for i, (line, lot) in enumerate(zip(self.start.lots, lots)): + if not i and not quantity_remaining: + self.record.quantity = line.quantity + self.record.lot = lot + self.record.save() + else: + with Transaction().set_context(_stock_move_split=True): + self.model.copy([self.record], { + 'quantity': line.quantity, + 'lot': lot.id, + }) + return 'end' + + +class MoveAddLotsStart(ModelView): + "Add Lots" + __name__ = 'stock.move.add.lots.start' + + product = fields.Many2One('product.product', "Product", readonly=True) + quantity = fields.Float( + "Quantity", digits=(16, Eval('unit_digits', 2)), readonly=True, + depends=['unit_digits']) + unit = fields.Many2One('product.uom', "Unit", readonly=True) + unit_digits = fields.Function( + fields.Integer("Unit Digits"), 'on_change_with_unit_digits') + quantity_remaining = fields.Function( + fields.Float( + "Quantity Remaining", digits=(16, Eval('unit_digits', 2)), + depends=['unit_digits']), + 'on_change_with_quantity_remaining') + + lots = fields.One2Many( + 'stock.move.add.lots.start.lot', 'parent', "Lots", + domain=[ + ('product', '=', Eval('product', -1)), + ], + states={ + 'readonly': ~Eval('quantity_remaining', 0), + }, + depends=['product', 'quantity_remaining']) + + duplicate_lot_number = fields.Integer( + "Duplicate Lot Number", + states={ + 'invisible': Len(Eval('lots')) != 1, + }, + depends=['lots'], + help="The number of times the lot must be duplicated.") + + @classmethod + def __setup__(cls): + super().__setup__() + cls._buttons.update( + duplicate_lot={ + 'invisible': Len(Eval('lots')) != 1, + 'readonly': (~Eval('duplicate_lot_number') + | (Eval('duplicate_lot_number', 0) <= 0)), + 'depends': ['lots', 'duplicate_lot_number'], + }, + ) + + @fields.depends('unit') + def on_change_with_unit_digits(self, name=None): + if self.unit: + return self.unit.digits + + @fields.depends('quantity', 'lots', 'unit') + def on_change_with_quantity_remaining(self, name=None): + if self.quantity is not None: + quantity = self.quantity + if self.lots: + for lot in self.lots: + quantity -= getattr(lot, 'quantity', 0) or 0 + if self.unit: + quantity = self.unit.round(quantity) + return quantity + + @ModelView.button_change( + 'lots', 'duplicate_lot_number', + methods=['on_change_with_quantity_remaining']) + def duplicate_lot(self): + lots = list(self.lots) + template, = self.lots + for i in range(self.duplicate_lot_number): + lot = copy(template) + lot._id = None + lots.append(lot) + self.lots = lots + self.quantity_remaining = self.on_change_with_quantity_remaining() + + +class MoveAddLotsStartLot(ModelView, LotMixin): + "Add Lots" + __name__ = 'stock.move.add.lots.start.lot' + + parent = fields.Many2One('stock.move.add.lots.start', "Parent") + quantity = fields.Float( + "Quantity", digits=(16, Eval('unit_digits', 2)), required=True, + depends=['unit_digits']) + unit_digits = fields.Function( + fields.Integer("Unit Digits"), 'on_change_with_unit_digits') + + @fields.depends( + 'parent', '_parent_parent.quantity_remaining') + def on_change_parent(self): + if (self.parent + and self.parent.quantity_remaining is not None): + self.quantity = self.parent.quantity_remaining + + @fields.depends('parent', '_parent_parent.unit_digits') + def on_change_with_unit_digits(self, name=None): + if self.parent: + return self.parent.unit_digits + + @fields.depends('number', 'product', methods=['_set_lot_values']) + def on_change_number(self): + pool = Pool() + Lot = pool.get('stock.lot') + if self.number and self.product: + lots = Lot.search([ + ('number', '=', self.number), + ('product', '=', self.product.id), + ]) + if len(lots) == 1: + lot, = lots + self._set_lot_values(lot) + + def _set_lot_values(self, lot): + pass + + def get_lot(self, move): + pool = Pool() + Lot = pool.get('stock.lot') + values = self._get_lot_values(move) + lots = Lot.search( + [(k, '=', v) for k, v in values.items()], + limit=1) + if lots: + lot, = lots + else: + lot = Lot() + for k, v in values.items(): + setattr(lot, k, v) + return lot + + def _get_lot_values(self, move): + return { + 'number': self.number, + 'product': move.product, + } + + class ShipmentIn(metaclass=PoolMeta): __name__ = 'stock.shipment.in' diff --git a/stock.xml b/stock.xml index 4a14b732ab..da81737aae 100644 --- a/stock.xml +++ b/stock.xml @@ -201,6 +201,36 @@ this repository contains the full copyright notices and license terms. --> move_tree + + add_lots_wizard + Add Lots + + + + + Add Lots + stock.move.add.lots + stock.move + + + + stock.move.add.lots.start + form + stock_move_add_lots_start_form + + + + stock.move.add.lots.start.lot + tree + stock_move_add_lots_start_lot_list + + + + duplicate_lot + Duplicate Lot + + + stock.period.cache.lot form diff --git a/tests/scenario_stock_lot_move_add.rst b/tests/scenario_stock_lot_move_add.rst new file mode 100644 index 0000000000..e93d3418dd --- /dev/null +++ b/tests/scenario_stock_lot_move_add.rst @@ -0,0 +1,108 @@ +=========================== +Stock Lot Move Add Scenario +=========================== + +Imports:: + + >>> 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 + +Activate modules:: + + >>> config = activate_modules('stock_lot') + +Create company:: + + >>> _ = create_company() + +Create product:: + + >>> 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('20') + >>> template.save() + >>> product, = template.products + +Get stock locations:: + + >>> Location = Model.get('stock.location') + >>> storage_loc, = Location.find([('code', '=', 'STO')]) + >>> customer_loc, = Location.find([('code', '=', 'CUS')]) + +Create a move:: + + >>> Move = Model.get('stock.move') + >>> move = Move() + >>> move.from_location = storage_loc + >>> move.to_location = customer_loc + >>> move.product = product + >>> move.quantity = 10 + >>> move.unit_price = Decimal('20') + >>> move.save() + +Create a lot:: + + >>> Lot = Model.get('stock.lot') + >>> lot2 = Lot(number='02', product=product) + >>> lot2.save() + +Add few lots:: + + >>> add_lots = Wizard('stock.move.add.lots', [move]) + >>> add_lots.form.product== product + True + >>> add_lots.form.quantity + 10.0 + >>> add_lots.form.unit == unit + True + >>> add_lots.form.quantity_remaining + 10.0 + + >>> lot = add_lots.form.lots.new() + >>> lot.quantity + 10.0 + >>> lot.number = '01' + >>> lot.quantity = 2 + >>> lot = add_lots.form.lots.new() + >>> lot.quantity + 8.0 + >>> lot.number = '02' + >>> lot.quantity = 1 + + >>> add_lots.execute('add') + +Check moves:: + + >>> move, = Move.find([('lot.number', '=', '01')]) + >>> move.quantity + 2.0 + >>> move, = Move.find([('lot.number', '=', '02')]) + >>> move.quantity + 1.0 + >>> move.lot == lot2 + True + >>> move, = Move.find([('lot', '=', None)]) + >>> move.quantity + 7.0 + +Add lot to remaining:: + + >>> add_lots = Wizard('stock.move.add.lots', [move]) + >>> lot = add_lots.form.lots.new() + >>> lot.number = '03' + >>> add_lots.execute('add') + + >>> len(Move.find([])) + 3 + >>> move.lot.number + '03' + >>> move.quantity + 7.0 diff --git a/tests/scenario_stock_lot_number.rst b/tests/scenario_stock_lot_number.rst new file mode 100644 index 0000000000..b14ffa6a16 --- /dev/null +++ b/tests/scenario_stock_lot_number.rst @@ -0,0 +1,51 @@ +========================= +Stock Lot Number Scenario +========================= + +Imports:: + + >>> from decimal import Decimal + >>> from proteus import Model + >>> from trytond.tests.tools import activate_modules + +Activate modules:: + + >>> config = activate_modules('stock_lot') + +Create lot sequence:: + + >>> Sequence = Model.get('ir.sequence') + >>> sequence = Sequence(name="Lot", code='stock.lot') + >>> sequence.save() + +Set default sequence:: + + >>> Configuration = Model.get('product.configuration') + >>> configuration = Configuration(1) + >>> configuration.default_lot_sequence = sequence + >>> configuration.save() + +Create product:: + + >>> ProductUom = Model.get('product.uom') + >>> ProductTemplate = Model.get('product.template') + >>> unit, = ProductUom.find([('name', '=', 'Unit')]) + + >>> template = ProductTemplate() + >>> template.lot_sequence == sequence + True + >>> template.name = 'Product' + >>> template.default_uom = unit + >>> template.type = 'goods' + >>> template.list_price = Decimal('20') + >>> template.save() + >>> product, = template.products + +Create lot without number:: + + >>> Lot = Model.get('stock.lot') + >>> lot = Lot(product=product) + >>> lot.save() + + >>> lot.number + '1' diff --git a/tests/test_stock_lot.py b/tests/test_stock_lot.py index 7fdaf10723..a380170d8e 100644 --- a/tests/test_stock_lot.py +++ b/tests/test_stock_lot.py @@ -325,4 +325,12 @@ def suite(): tearDown=doctest_teardown, encoding='utf-8', checker=doctest_checker, optionflags=doctest.REPORT_ONLY_FIRST_FAILURE)) + suite.addTests(doctest.DocFileSuite('scenario_stock_lot_number.rst', + tearDown=doctest_teardown, encoding='utf-8', + checker=doctest_checker, + optionflags=doctest.REPORT_ONLY_FIRST_FAILURE)) + suite.addTests(doctest.DocFileSuite('scenario_stock_lot_move_add.rst', + tearDown=doctest_teardown, encoding='utf-8', + checker=doctest_checker, + optionflags=doctest.REPORT_ONLY_FIRST_FAILURE)) return suite diff --git a/tryton.cfg b/tryton.cfg index 3b36020c5d..7567b8d14b 100644 --- a/tryton.cfg +++ b/tryton.cfg @@ -5,6 +5,7 @@ depends: product stock xml: + ir.xml stock.xml product.xml message.xml diff --git a/view/move_form.xml b/view/move_form.xml index dc45fe4e9c..38ab1b7105 100644 --- a/view/move_form.xml +++ b/view/move_form.xml @@ -7,4 +7,7 @@ this repository contains the full copyright notices and license terms. --> + +