From 43967e302bfd530d91cea23624af33e938e101a4 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 28 Aug 2019 21:12:16 +1000 Subject: [PATCH] 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)