diff --git a/InvenTree/part/fixtures/part.yaml b/InvenTree/part/fixtures/part.yaml index f9d7c9f4b4..632a265e23 100644 --- a/InvenTree/part/fixtures/part.yaml +++ b/InvenTree/part/fixtures/part.yaml @@ -1,12 +1,14 @@ # Create some fasteners - model: part.part + pk: 1 fields: name: 'M2x4 LPHS' description: 'M2x4 low profile head screw' category: 8 - model: part.part + pk: 2 fields: name: 'M3x12 SHCS' description: 'M3x12 socket head cap screw' @@ -15,6 +17,7 @@ # Create some resistors - model: part.part + pk: 3 fields: name: 'R_2K2_0805' description: '2.2kOhm resistor in 0805 package' @@ -35,6 +38,7 @@ category: 3 - model: part.part + pk: 25 fields: name: 'Widget' description: 'A watchamacallit' diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index b781287930..d119c3094e 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -493,6 +493,7 @@ class Part(models.Model): hash = hashlib.md5('bom seed'.encode()) for item in self.bom_items.all(): + hash.update(str(item.sub_part.id).encode()) hash.update(str(item.sub_part.full_name).encode()) hash.update(str(item.quantity).encode()) hash.update(str(item.note).encode()) diff --git a/InvenTree/part/test_category.py b/InvenTree/part/test_category.py index ddbcf3babc..ea52396342 100644 --- a/InvenTree/part/test_category.py +++ b/InvenTree/part/test_category.py @@ -111,11 +111,11 @@ class CategoryTest(TestCase): def test_default_locations(self): """ Test traversal for default locations """ - self.assertEqual(str(self.fasteners.default_location), 'Office/Drawer') + self.assertEqual(str(self.fasteners.default_location), 'Office/Drawer_1') # Test that parts in this location return the same default location, too for p in self.fasteners.children.all(): - self.assert_equal(p.get_default_location(), 'Office/Drawer') + self.assert_equal(p.get_default_location(), 'Office/Drawer_1') # Any part under electronics should default to 'Home' R1 = Part.objects.get(name='R_2K2_0805') diff --git a/InvenTree/static/script/inventree/stock.js b/InvenTree/static/script/inventree/stock.js index 4f32c3f753..222bca8f20 100644 --- a/InvenTree/static/script/inventree/stock.js +++ b/InvenTree/static/script/inventree/stock.js @@ -64,7 +64,7 @@ function updateStock(items, options={}) { html += ''; - html += '' + item.part.name + ''; + html += '' + item.part.full_name + ''; if (item.location) { html += '' + item.location.name + ''; @@ -289,7 +289,7 @@ function moveStockItems(items, options) { html += ""; - html += "" + item.part.name + ""; + html += "" + item.part.full_name + ""; html += "" + item.location.pathstring + ""; html += "" + item.quantity + ""; diff --git a/InvenTree/stock/fixtures/location.yaml b/InvenTree/stock/fixtures/location.yaml index f04b192a7d..f34490af29 100644 --- a/InvenTree/stock/fixtures/location.yaml +++ b/InvenTree/stock/fixtures/location.yaml @@ -5,12 +5,14 @@ fields: name: 'Home' description: 'My house' + - model: stock.stocklocation pk: 2 fields: name: 'Bathroom' description: 'Where I keep my bath' parent: 1 + - model: stock.stocklocation pk: 3 fields: @@ -23,9 +25,24 @@ fields: name: 'Office' description: 'Place of work' + - model: stock.stocklocation pk: 5 fields: - name: 'Drawer' + name: 'Drawer_1' description: 'In my desk' + parent: 4 + +- model: stock.stocklocation + pk: 6 + fields: + name: 'Drawer_2' + description: 'Also in my desk' + parent: 4 + +- model: stock.stocklocation + pk: 7 + fields: + name: 'Drawer_3' + description: 'Again, in my desk' parent: 4 \ No newline at end of file diff --git a/InvenTree/stock/fixtures/stock.yaml b/InvenTree/stock/fixtures/stock.yaml new file mode 100644 index 0000000000..96e0a3ab72 --- /dev/null +++ b/InvenTree/stock/fixtures/stock.yaml @@ -0,0 +1,47 @@ +# Create some sample stock items + +# 4,000 screws in the dining room +- model: stock.stockitem + fields: + part: 1 + location: 3 + batch: 'B123' + quantity: 4000 + +# 5,000 screws in the bathroom +- model: stock.stockitem + fields: + part: 1 + location: 2 + quantity: 5000 + +# 1234 2K2 resistors in 'Drawer_1' +- model: stock.stockitem + pk: 1234 + fields: + part: 3 + location: 5 + quantity: 1234 + +# Some widgets in drawer 3 +- model: stock.stockitem + pk: 100 + fields: + part: 25 + location: 7 + quantity: 10 + delete_on_deplete: False + +- model: stock.stockitem + pk: 101 + fields: + part: 25 + location: 7 + quantity: 5 + +- model: stock.stockitem + pk: 102 + fields: + part: 25 + location: 7 + quantity: 3 \ No newline at end of file diff --git a/InvenTree/stock/migrations/0016_auto_20190512_2119.py b/InvenTree/stock/migrations/0016_auto_20190512_2119.py new file mode 100644 index 0000000000..582e68d277 --- /dev/null +++ b/InvenTree/stock/migrations/0016_auto_20190512_2119.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2 on 2019-05-12 11:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0015_stockitem_delete_on_deplete'), + ] + + operations = [ + migrations.AlterField( + model_name='stockitem', + name='updated', + field=models.DateField(auto_now=True, null=True), + ), + ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 09b9cafff4..950d6237dd 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -53,11 +53,13 @@ class StockLocation(InvenTreeTree): """ Return the number of StockItem objects which live in or under this category """ - return len(StockItem.objects.filter(location__in=self.getUniqueChildren())) + return StockItem.objects.filter(location__in=self.getUniqueChildren()).count() @property def item_count(self): - + """ Simply returns the number of stock items in this location. + Required for tree view serializer. + """ return self.stock_item_count @@ -211,7 +213,7 @@ class StockItem(models.Model): quantity = models.PositiveIntegerField(validators=[MinValueValidator(0)], default=1) - updated = models.DateField(auto_now=True) + updated = models.DateField(auto_now=True, null=True) # last time the stock was checked / counted stocktake_date = models.DateField(blank=True, null=True) @@ -444,9 +446,6 @@ class StockItem(models.Model): """ Remove items from stock """ - if self.quantity == 0: - return False - quantity = int(quantity) if quantity <= 0 or self.infinite: diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py index d5b1f8c3f1..417c995b6c 100644 --- a/InvenTree/stock/tests.py +++ b/InvenTree/stock/tests.py @@ -1,4 +1,5 @@ from django.test import TestCase +from django.db.models import Sum from .models import StockLocation, StockItem, StockItemTracking from part.models import Part @@ -9,100 +10,123 @@ class StockTest(TestCase): Tests to ensure that the stock location tree functions correcly """ + fixtures = [ + 'category', + 'part', + 'location', + 'stock', + ] + def setUp(self): - # Initialize some categories - self.loc1 = StockLocation.objects.create(name='L0', - description='Top level category', - parent=None) + # Extract some shortcuts from the fixtures + self.home = StockLocation.objects.get(name='Home') + self.bathroom = StockLocation.objects.get(name='Bathroom') + self.diningroom = StockLocation.objects.get(name='Dining Room') - self.loc2 = StockLocation.objects.create(name='L1.1', - description='Second level 1/2', - parent=self.loc1) + self.office = StockLocation.objects.get(name='Office') + self.drawer1 = StockLocation.objects.get(name='Drawer_1') + self.drawer2 = StockLocation.objects.get(name='Drawer_2') + self.drawer3 = StockLocation.objects.get(name='Drawer_3') - self.loc3 = StockLocation.objects.create(name='L1.2', - description='Second level 2/2', - parent=self.loc1) + def test_loc_count(self): + self.assertEqual(StockLocation.objects.count(), 7) - self.loc4 = StockLocation.objects.create(name='L2.1', - description='Third level 1/2', - parent=self.loc2) - - self.loc5 = StockLocation.objects.create(name='L2.2', - description='Third level 2/2', - parent=self.loc3) - - # Add some items to loc4 (all copies of a single part) - p = Part.objects.create(name='ACME Part', description='This is a part!') - - StockItem.objects.create(part=p, location=self.loc4, quantity=1000) - StockItem.objects.create(part=p, location=self.loc4, quantity=250) - StockItem.objects.create(part=p, location=self.loc4, quantity=12) - - def test_simple(self): + def test_url(self): it = StockItem.objects.get(pk=2) self.assertEqual(it.get_absolute_url(), '/stock/item/2/') - self.assertEqual(self.loc4.get_absolute_url(), '/stock/location/4/') + + self.assertEqual(self.home.get_absolute_url(), '/stock/location/1/') + + def test_barcode(self): + barcode = self.office.format_barcode() + + self.assertIn('"name": "Office"', barcode) def test_strings(self): - it = StockItem.objects.get(pk=2) - self.assertEqual(str(it), '250 x ACME Part @ L2.1') + it = StockItem.objects.get(pk=1) + self.assertEqual(str(it), '4000 x M2x4 LPHS @ Dining Room') - def test_parent(self): - self.assertEqual(StockLocation.objects.count(), 5) - self.assertEqual(self.loc1.parent, None) - self.assertEqual(self.loc2.parent, self.loc1) - self.assertEqual(self.loc5.parent, self.loc3) + def test_parent_locations(self): + + self.assertEqual(self.office.parent, None) + self.assertEqual(self.drawer1.parent, self.office) + self.assertEqual(self.drawer2.parent, self.office) + self.assertEqual(self.drawer3.parent, self.office) + + self.assertEqual(self.drawer3.pathstring, 'Office/Drawer_3') + + # Move one of the drawers + self.drawer3.parent = self.home + self.assertNotEqual(self.drawer3.parent, self.office) + + self.assertEqual(self.drawer3.pathstring, 'Home/Drawer_3') def test_children(self): - self.assertTrue(self.loc1.has_children) - self.assertFalse(self.loc5.has_children) + self.assertTrue(self.office.has_children) + self.assertFalse(self.drawer2.has_children) - childs = self.loc1.getUniqueChildren() + childs = self.office.getUniqueChildren() - self.assertIn(self.loc2.id, childs) - self.assertIn(self.loc4.id, childs) + self.assertIn(self.drawer1.id, childs) + self.assertIn(self.drawer2.id, childs) - def test_paths(self): - self.assertEqual(self.loc5.pathstring, 'L0/L1.2/L2.2') + self.assertNotIn(self.bathroom.id, childs) def test_items(self): - # Location 5 should have no items - self.assertFalse(self.loc5.has_items()) - self.assertFalse(self.loc3.has_items()) + self.assertTrue(self.drawer1.has_items()) + self.assertTrue(self.drawer3.has_items()) + self.assertFalse(self.drawer2.has_items()) - # Location 4 should have three stock items - self.assertEqual(self.loc4.stock_items.count(), 3) + # Drawer 3 should have three stock items + self.assertEqual(self.drawer3.stock_items.count(), 3) + self.assertEqual(self.drawer3.item_count, 3) def test_stock_count(self): part = Part.objects.get(pk=1) - # There should be 1262 items in stock - self.assertEqual(part.total_stock, 1262) + # There should be 5000 screws in stock + self.assertEqual(part.total_stock, 9000) + + # There should be 18 widgets in stock + self.assertEqual(StockItem.objects.filter(part=25).aggregate(Sum('quantity'))['quantity__sum'], 18) def test_delete_location(self): + + # How many stock items are there? + n_stock = StockItem.objects.count() + + # What parts are in drawer 3? + stock_ids = [part.id for part in StockItem.objects.filter(location=self.drawer3.id)] + # Delete location - parts should move to parent location - self.loc4.delete() + self.drawer3.delete() - # There should still be 3 stock items - self.assertEqual(StockItem.objects.count(), 3) + # There should still be the same number of parts + self.assertEqual(StockItem.objects.count(), n_stock) - # Parent location should have moved up to loc2 - for it in StockItem.objects.all(): - self.assertEqual(it.location, self.loc2) + # stock should have moved + for s_id in stock_ids: + s_item = StockItem.objects.get(id=s_id) + self.assertEqual(s_item.location, self.office) def test_move(self): - # Move the first stock item to loc5 + """ Test stock movement functions """ + + # Move 4,000 screws to the bathroom it = StockItem.objects.get(pk=1) - self.assertNotEqual(it.location, self.loc5) - self.assertTrue(it.move(self.loc5, 'Moved to another place', None)) - self.assertEqual(it.location, self.loc5) + self.assertNotEqual(it.location, self.bathroom) + self.assertTrue(it.move(self.bathroom, 'Moved to the bathroom', None)) + self.assertEqual(it.location, self.bathroom) + + # There now should be 2 lots of screws in the bathroom + self.assertEqual(StockItem.objects.filter(part=1, location=self.bathroom).count(), 2) # Check that a tracking item was added track = StockItemTracking.objects.filter(item=it).latest('id') self.assertEqual(track.item, it) self.assertIn('Moved to', track.title) - self.assertEqual(track.notes, 'Moved to another place') + self.assertEqual(track.notes, 'Moved to the bathroom') def test_self_move(self): # Try to move an item to its current location (should fail) @@ -114,10 +138,47 @@ class StockTest(TestCase): # Ensure tracking info was not added self.assertEqual(it.tracking_info.count(), n) + def test_partial_move(self): + w1 = StockItem.objects.get(pk=100) + + # Move 6 of the units + self.assertTrue(w1.move(self.diningroom, 'Moved', None, quantity=6)) + self.assertEqual(w1.quantity, 6) + + # There should also be a new object still in drawer3 + self.assertEqual(StockItem.objects.filter(part=25).count(), 4) + widget = StockItem.objects.get(location=self.drawer3.id, part=25, quantity=4) + + # Try to move negative units + self.assertFalse(widget.move(self.bathroom, 'Test', None, quantity=-100)) + self.assertEqual(StockItem.objects.filter(part=25).count(), 4) + + # Try to move to a blank location + self.assertFalse(widget.move(None, 'null', None)) + + def test_split_stock(self): + # Split the 1234 x 2K2 resistors in Drawer_1 + + N = StockItem.objects.filter(part=3).count() + + stock = StockItem.objects.get(id=1234) + stock.splitStock(1000, None) + self.assertEqual(stock.quantity, 234) + + # There should be a new stock item too! + self.assertEqual(StockItem.objects.filter(part=3).count(), N + 1) + + # Try to split a negative quantity + stock.splitStock(-10, None) + self.assertEqual(StockItem.objects.filter(part=3).count(), N + 1) + + stock.splitStock(stock.quantity, None) + self.assertEqual(StockItem.objects.filter(part=3).count(), N + 1) + def test_stocktake(self): # Perform stocktake it = StockItem.objects.get(pk=2) - self.assertEqual(it.quantity, 250) + self.assertEqual(it.quantity, 5000) it.stocktake(255, None, notes='Counted items!') self.assertEqual(it.quantity, 255) @@ -147,6 +208,8 @@ class StockTest(TestCase): self.assertIn('Added', track.title) self.assertIn('Added some items', track.notes) + self.assertFalse(it.add_stock(-10, None)) + def test_take_stock(self): it = StockItem.objects.get(pk=2) n = it.quantity @@ -160,3 +223,24 @@ class StockTest(TestCase): self.assertIn('Removed', track.title) self.assertIn('Removed some items', track.notes) self.assertTrue(it.has_tracking_info) + + # Test that negative quantity does nothing + self.assertFalse(it.take_stock(-10, None)) + + def test_deplete_stock(self): + + w1 = StockItem.objects.get(pk=100) + w2 = StockItem.objects.get(pk=101) + + # Take 25 units from w1 + w1.take_stock(30, None, notes='Took 30') + + # Get from database again + w1 = StockItem.objects.get(pk=100) + self.assertEqual(w1.quantity, 0) + + # Take 25 units from w2 (will be deleted) + w2.take_stock(30, None, notes='Took 30') + + with self.assertRaises(StockItem.DoesNotExist): + w2 = StockItem.objects.get(pk=101)