From 88b90281f5128b23fe820046a1e505d3dcd24cd0 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 28 Aug 2019 19:56:35 +1000 Subject: [PATCH 01/11] Do not enforce serialization when creating a stock item --- InvenTree/stock/forms.py | 3 +- InvenTree/stock/views.py | 76 +++++++++++++++++++++------------------- 2 files changed, 42 insertions(+), 37 deletions(-) diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index 46a50521dd..ecc71f3423 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -7,6 +7,7 @@ from __future__ import unicode_literals from django import forms from django.forms.utils import ErrorDict +from django.utils.translation import ugettext as _ from InvenTree.forms import HelperForm from .models import StockLocation, StockItem, StockItemTracking @@ -27,7 +28,7 @@ class EditStockLocationForm(HelperForm): class CreateStockItemForm(HelperForm): """ Form for creating a new StockItem """ - serial_numbers = forms.CharField(label='Serial numbers', required=False, help_text='Enter unique serial numbers') + serial_numbers = forms.CharField(label='Serial numbers', required=False, help_text=_('Enter unique serial numbers (or leave blank)')) class Meta: model = StockItem diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 3d36eb91b9..6bf98fd26d 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -593,47 +593,51 @@ class StockItemCreate(AjaxCreateView): if part.trackable: sn = request.POST.get('serial_numbers', '') - try: - serials = ExtractSerialNumbers(sn, quantity) + sn = str(sn).strip() - existing = [] + # If user has specified a range of serial numbers + if len(sn) > 0: + try: + serials = ExtractSerialNumbers(sn, quantity) - for serial in serials: - if not StockItem.check_serial_number(part, serial): - existing.append(serial) + existing = [] - if len(existing) > 0: - exists = ",".join([str(x) for x in existing]) - form.errors['serial_numbers'] = [_('The following serial numbers already exist: ({sn})'.format(sn=exists))] + for serial in serials: + if not StockItem.check_serial_number(part, serial): + existing.append(serial) + + if len(existing) > 0: + exists = ",".join([str(x) for x in existing]) + form.errors['serial_numbers'] = [_('The following serial numbers already exist: ({sn})'.format(sn=exists))] + valid = False + + # At this point we have a list of serial numbers which we know are valid, + # and do not currently exist + form.clean() + + data = form.cleaned_data + + for serial in serials: + # Create a new stock item for each serial number + item = StockItem( + part=part, + quantity=1, + serial=serial, + supplier_part=data.get('supplier_part'), + location=data.get('location'), + batch=data.get('batch'), + delete_on_deplete=False, + status=data.get('status'), + notes=data.get('notes'), + URL=data.get('URL'), + ) + + item.save() + + except ValidationError as e: + form.errors['serial_numbers'] = e.messages valid = False - # At this point we have a list of serial numbers which we know are valid, - # and do not currently exist - form.clean() - - data = form.cleaned_data - - for serial in serials: - # Create a new stock item for each serial number - item = StockItem( - part=part, - quantity=1, - serial=serial, - supplier_part=data.get('supplier_part'), - location=data.get('location'), - batch=data.get('batch'), - delete_on_deplete=False, - status=data.get('status'), - notes=data.get('notes'), - URL=data.get('URL'), - ) - - item.save() - - except ValidationError as e: - form.errors['serial_numbers'] = e.messages - valid = False - else: # For non-serialized items, simply save the form. # We need to call _post_clean() here because it is prevented in the form implementation From bc03ae53bd1b22084d6deb5e85e8680d39f47307 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 28 Aug 2019 20:01:38 +1000 Subject: [PATCH 02/11] Changes for clean of StockItem --- InvenTree/stock/models.py | 7 +++++-- InvenTree/stock/views.py | 3 +++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index b5a88d4e66..5c90db78b5 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -204,12 +204,15 @@ class StockItem(models.Model): }) if self.quantity == 0: + self.quantity = 1 + + elif self.quantity > 1: raise ValidationError({ 'quantity': _('Quantity must be 1 for item with a serial number') }) - if self.delete_on_deplete: - raise ValidationError({'delete_on_deplete': _("Must be set to False for item with a serial number")}) + # Serial numbered items cannot be deleted on depletion + self.delete_on_deplete = False # A template part cannot be instantiated as a StockItem if self.part.is_template: diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 6bf98fd26d..4ad10d1ada 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -467,6 +467,9 @@ class StockItemCreate(AjaxCreateView): Parameters can be pre-filled by passing query items: - part: The part of which the new StockItem is an instance - location: The location of the new StockItem + + If the parent part is a "tracked" part, provide an option to create uniquely serialized items + rather than a bulk quantity of stock items """ model = StockItem From 43967e302bfd530d91cea23624af33e938e101a4 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 28 Aug 2019 21:12:16 +1000 Subject: [PATCH 03/11] Add ablity to serialize an existing quantity of stock - Do not have to serialize all the stock - Add tests - Add function to copy entire stock transaction history --- InvenTree/part/fixtures/part.yaml | 1 + InvenTree/part/models.py | 3 +- InvenTree/stock/models.py | 94 ++++++++++++++++++++++++++++++- InvenTree/stock/tests.py | 78 +++++++++++++++++++++++++ 4 files changed, 172 insertions(+), 4 deletions(-) diff --git a/InvenTree/part/fixtures/part.yaml b/InvenTree/part/fixtures/part.yaml index 3f7fbb9886..77b96a3471 100644 --- a/InvenTree/part/fixtures/part.yaml +++ b/InvenTree/part/fixtures/part.yaml @@ -43,6 +43,7 @@ name: 'Widget' description: 'A watchamacallit' category: 7 + trackable: true - model: part.part pk: 50 diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 382eccdf07..4a4c5fb606 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -843,7 +843,8 @@ class Part(models.Model): # Copy the BOM data if kwargs.get('bom', False): for item in other.bom_items.all(): - # Point the item to THIS part + # Point the item to THIS part. + # Set the pk to None so a new entry is created. item.part = self item.pk = None item.save() diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 5c90db78b5..86b1c7b2b9 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -138,6 +138,12 @@ class StockItem(models.Model): if not part.trackable: return False + # Return False if an invalid serial number is supplied + try: + serial_number = int(serial_number) + except ValueError: + return False + items = StockItem.objects.filter(serial=serial_number) # Is this part a variant? If so, check S/N across all sibling variants @@ -367,12 +373,91 @@ class StockItem(models.Model): track.save() @transaction.atomic - def serializeStock(self, serials, user): + def serializeStock(self, quantity, serials, user, location=None): """ Split this stock item into unique serial numbers. + + - Quantity can be less than or equal to the quantity of the stock item + - Number of serial numbers must match the quantity + - Provided serial numbers must not already be in use + + Args: + quantity: Number of items to serialize (integer) + serials: List of serial numbers (list) + user: User object associated with action + location: If specified, serialized items will be placed in the given location """ - # TODO - pass + # Cannot serialize stock that is already serialized! + if self.serialized: + return + + # Do not serialize non-trackable parts + if not self.part.trackable: + raise ValueError({"part": _("Cannot serialize a non-trackable part")}) + + # Quantity must be a valid integer value + try: + quantity = int(quantity) + except ValueError: + raise ValueError({"quantity": _("Quantity must be integer")}) + + if quantity <= 0: + raise ValueError({"quantity": _("Quantity must be greater than zero")}) + + if quantity > self.quantity: + raise ValidationError({"quantity": _("Quantity must not exceed stock quantity")}) + + if not type(serials) in [list, tuple]: + raise ValueError({"serials": _("Serial numbers must be a list of integers")}) + + if any([type(i) is not int for i in serials]): + raise ValueError({"serials": _("Serial numbers must be a list of integers")}) + + if not quantity == len(serials): + raise ValueError({"quantity": _("Quantity does not match serial numbers")}) + + # Test if each of the serial numbers are valid + existing = [] + + for serial in serials: + if not StockItem.check_serial_number(self.part, serial): + existing.append(serial) + + if len(existing) > 0: + raise ValidationError({"serials": _("Serial numbers already exist: ") + str(existing)}) + + # Create a new stock item for each unique serial number + for serial in serials: + + # Create a copy of this StockItem + new_item = StockItem.objects.get(pk=self.pk) + new_item.quantity = 1 + new_item.serial = serial + new_item.pk = None + + if location: + new_item.location = location + + new_item.save() + + # Copy entire transaction history + new_item.copyHistoryFrom(self) + + # Create a new stock tracking item + self.addTransactionNote(_('Add serial number'), user) + + # Remove the equivalent number of items + self.take_stock(quantity, user, notes=_('Serialized {n} items'.format(n=quantity))) + + @transaction.atomic + def copyHistoryFrom(self, other): + """ Copy stock history from another part """ + + for item in other.tracking_info.all(): + + item.item = self + item.pk = None + item.save() @transaction.atomic def splitStock(self, quantity, user): @@ -414,6 +499,9 @@ class StockItem(models.Model): new_stock.save() + # Copy the transaction history + new_stock.copyHistoryFrom(self) + # Add a new tracking item for the new stock item new_stock.addTransactionNote( "Split from existing stock", diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py index 417c995b6c..63423ec291 100644 --- a/InvenTree/stock/tests.py +++ b/InvenTree/stock/tests.py @@ -1,5 +1,7 @@ from django.test import TestCase from django.db.models import Sum +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError from .models import StockLocation, StockItem, StockItemTracking from part.models import Part @@ -28,6 +30,14 @@ class StockTest(TestCase): self.drawer2 = StockLocation.objects.get(name='Drawer_2') self.drawer3 = StockLocation.objects.get(name='Drawer_3') + # Create a user + User = get_user_model() + User.objects.create_user('username', 'user@email.com', 'password') + + self.client.login(username='username', password='password') + + self.user = User.objects.get(username='username') + def test_loc_count(self): self.assertEqual(StockLocation.objects.count(), 7) @@ -244,3 +254,71 @@ class StockTest(TestCase): with self.assertRaises(StockItem.DoesNotExist): w2 = StockItem.objects.get(pk=101) + + def test_serialize_stock_invalid(self): + """ + Test manual serialization of parts. + Each of these tests should fail + """ + + # Test serialization of non-serializable part + item = StockItem.objects.get(pk=1234) + + with self.assertRaises(ValueError): + item.serializeStock(5, [1, 2, 3, 4, 5], self.user) + + # Pick a StockItem which can actually be serialized + item = StockItem.objects.get(pk=100) + + # Try an invalid quantity + with self.assertRaises(ValueError): + item.serializeStock("k", [], self.user) + + with self.assertRaises(ValueError): + item.serializeStock(-1, [], self.user) + + # Try invalid serial numbers + with self.assertRaises(ValueError): + item.serializeStock(3, [1, 2, 'k'], self.user) + + with self.assertRaises(ValueError): + item.serializeStock(3, "hello", self.user) + + def test_seiralize_stock_valid(self): + """ Perform valid stock serializations """ + + # There are 10 of these in stock + # Item will deplete when deleted + item = StockItem.objects.get(pk=100) + item.delete_on_deplete = True + item.save() + + n = StockItem.objects.filter(part=25).count() + + self.assertEqual(item.quantity, 10) + + item.serializeStock(3, [1, 2, 3], self.user) + + self.assertEqual(item.quantity, 7) + + # Try to serialize again (with same serial numbers) + with self.assertRaises(ValidationError): + item.serializeStock(3, [1, 2, 3], self.user) + + # Try to serialize too many items + with self.assertRaises(ValidationError): + item.serializeStock(13, [1, 2, 3], self.user) + + # Serialize some more stock + item.serializeStock(5, [6, 7, 8, 9, 10], self.user) + + self.assertEqual(item.quantity, 2) + + # There should be 8 more items now + self.assertEqual(StockItem.objects.filter(part=25).count(), n + 8) + + # Serialize the remainder of the stock + item.serializeStock(2, [99, 100], self.user) + + # Two more items but the original has been deleted + self.assertEqual(StockItem.objects.filter(part=25).count(), n + 9) From 3b8f5872ac13c0ea350ced83179889d425fc95d5 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 28 Aug 2019 21:21:26 +1000 Subject: [PATCH 04/11] Add button to serialize stock --- InvenTree/stock/templates/stock/item.html | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/InvenTree/stock/templates/stock/item.html b/InvenTree/stock/templates/stock/item.html index 62e46c8f84..2d59c9f84e 100644 --- a/InvenTree/stock/templates/stock/item.html +++ b/InvenTree/stock/templates/stock/item.html @@ -24,6 +24,11 @@ + {% if item.part.trackable %} + + {% endif %} {% endif %}