# -*- coding: utf-8 -*- from django.test import TestCase from django.core.exceptions import ValidationError from django.db.utils import IntegrityError from InvenTree import status_codes as status from build.models import Build, BuildItem, get_next_build_number from part.models import Part, BomItem, BomItemSubstitute from stock.models import StockItem class BuildTestBase(TestCase): """ Run some tests to ensure that the Build model is working properly. """ def setUp(self): """ Initialize data to use for these tests. The base Part 'assembly' has a BOM consisting of three parts: - 5 x sub_part_1 - 3 x sub_part_2 - 2 x sub_part_3 (trackable) We will build 10x 'assembly' parts, in two build outputs: - 3 x output_1 - 7 x output_2 """ # Create a base "Part" self.assembly = Part.objects.create( name="An assembled part", description="Why does it matter what my description is?", assembly=True, trackable=True, ) self.sub_part_1 = Part.objects.create( name="Widget A", description="A widget", component=True ) self.sub_part_2 = Part.objects.create( name="Widget B", description="A widget", component=True ) self.sub_part_3 = Part.objects.create( name="Widget C", description="A widget", component=True, trackable=True ) # Create BOM item links for the parts self.bom_item_1 = BomItem.objects.create( part=self.assembly, sub_part=self.sub_part_1, quantity=5 ) self.bom_item_2 = BomItem.objects.create( part=self.assembly, sub_part=self.sub_part_2, quantity=3 ) # sub_part_3 is trackable! self.bom_item_3 = BomItem.objects.create( part=self.assembly, sub_part=self.sub_part_3, quantity=2 ) ref = get_next_build_number() # Create a "Build" object to make 10x objects self.build = Build.objects.create( reference=ref, title="This is a build", part=self.assembly, quantity=10 ) # Create some build output (StockItem) objects self.output_1 = StockItem.objects.create( part=self.assembly, quantity=3, is_building=True, build=self.build ) self.output_2 = StockItem.objects.create( part=self.assembly, quantity=7, is_building=True, build=self.build, ) # Create some stock items to assign to the build self.stock_1_1 = StockItem.objects.create(part=self.sub_part_1, quantity=3) self.stock_1_2 = StockItem.objects.create(part=self.sub_part_1, quantity=100) self.stock_2_1 = StockItem.objects.create(part=self.sub_part_2, quantity=5) self.stock_2_2 = StockItem.objects.create(part=self.sub_part_2, quantity=5) self.stock_2_2 = StockItem.objects.create(part=self.sub_part_2, quantity=5) self.stock_2_2 = StockItem.objects.create(part=self.sub_part_2, quantity=5) self.stock_2_2 = StockItem.objects.create(part=self.sub_part_2, quantity=5) self.stock_3_1 = StockItem.objects.create(part=self.sub_part_3, quantity=1000) class BuildTest(BuildTestBase): def test_ref_int(self): """ Test the "integer reference" field used for natural sorting """ for ii in range(10): build = Build( reference=f"{ii}_abcde", quantity=1, part=self.assembly, title="Making some parts" ) self.assertEqual(build.reference_int, 0) build.save() # After saving, the integer reference should have been updated self.assertEqual(build.reference_int, ii) def test_init(self): # Perform some basic tests before we start the ball rolling self.assertEqual(StockItem.objects.count(), 10) # Build is PENDING self.assertEqual(self.build.status, status.BuildStatus.PENDING) # Build has two build outputs self.assertEqual(self.build.output_count, 2) # None of the build outputs have been completed for output in self.build.get_build_outputs().all(): self.assertFalse(self.build.is_fully_allocated(output)) self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_1, self.output_1)) self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2, self.output_2)) self.assertEqual(self.build.unallocated_quantity(self.bom_item_1, self.output_1), 15) self.assertEqual(self.build.unallocated_quantity(self.bom_item_1, self.output_2), 35) self.assertEqual(self.build.unallocated_quantity(self.bom_item_2, self.output_1), 9) self.assertEqual(self.build.unallocated_quantity(self.bom_item_2, self.output_2), 21) self.assertFalse(self.build.is_complete) def test_build_item_clean(self): # Ensure that dodgy BuildItem objects cannot be created stock = StockItem.objects.create(part=self.assembly, quantity=99) # Create a BuiltItem which points to an invalid StockItem b = BuildItem(stock_item=stock, build=self.build, quantity=10) with self.assertRaises(ValidationError): b.save() # Create a BuildItem which has too much stock assigned b = BuildItem(stock_item=self.stock_1_1, build=self.build, quantity=9999999) with self.assertRaises(ValidationError): b.clean() # Negative stock? Not on my watch! b = BuildItem(stock_item=self.stock_1_1, build=self.build, quantity=-99) with self.assertRaises(ValidationError): b.clean() # Ok, what about we make one that does *not* fail? b = BuildItem(stock_item=self.stock_1_2, build=self.build, install_into=self.output_1, quantity=10) b.save() def test_duplicate_bom_line(self): # Try to add a duplicate BOM item - it should fail! with self.assertRaises(IntegrityError): BomItem.objects.create( part=self.assembly, sub_part=self.sub_part_1, quantity=99 ) def allocate_stock(self, output, allocations): """ Allocate stock to this build, against a particular output Args: output - StockItem object (or None) allocations - Map of {StockItem: quantity} """ for item, quantity in allocations.items(): BuildItem.objects.create( build=self.build, stock_item=item, quantity=quantity, install_into=output ) def test_partial_allocation(self): """ Test partial allocation of stock """ # Fully allocate tracked stock against build output 1 self.allocate_stock( self.output_1, { self.stock_3_1: 6, } ) self.assertTrue(self.build.is_fully_allocated(self.output_1)) # Partially allocate tracked stock against build output 2 self.allocate_stock( self.output_2, { self.stock_3_1: 1, } ) self.assertFalse(self.build.is_fully_allocated(self.output_2)) # Partially allocate untracked stock against build self.allocate_stock( None, { self.stock_1_1: 1, self.stock_2_1: 1 } ) self.assertFalse(self.build.is_fully_allocated(None)) unallocated = self.build.unallocated_bom_items(None) self.assertEqual(len(unallocated), 2) self.allocate_stock( None, { self.stock_1_2: 100, } ) self.assertFalse(self.build.is_fully_allocated(None)) unallocated = self.build.unallocated_bom_items(None) self.assertEqual(len(unallocated), 1) self.build.unallocateStock() unallocated = self.build.unallocated_bom_items(None) self.assertEqual(len(unallocated), 2) self.assertFalse(self.build.are_untracked_parts_allocated()) self.stock_2_1.quantity = 500 self.stock_2_1.save() # Now we "fully" allocate the untracked untracked items self.allocate_stock( None, { self.stock_1_2: 50, self.stock_2_1: 50, } ) self.assertTrue(self.build.are_untracked_parts_allocated()) def test_cancel(self): """ Test cancellation of the build """ # TODO """ self.allocate_stock(50, 50, 200, self.output_1) self.build.cancel_build(None) self.assertEqual(BuildItem.objects.count(), 0) """ pass def test_complete(self): """ Test completion of a build output """ self.stock_1_1.quantity = 1000 self.stock_1_1.save() self.stock_2_1.quantity = 30 self.stock_2_1.save() # Allocate non-tracked parts self.allocate_stock( None, { self.stock_1_1: self.stock_1_1.quantity, # Allocate *all* stock from this item self.stock_1_2: 10, self.stock_2_1: 30 } ) # Allocate tracked parts to output_1 self.allocate_stock( self.output_1, { self.stock_3_1: 6 } ) # Allocate tracked parts to output_2 self.allocate_stock( self.output_2, { self.stock_3_1: 14 } ) self.assertTrue(self.build.is_fully_allocated(None)) self.assertTrue(self.build.is_fully_allocated(self.output_1)) self.assertTrue(self.build.is_fully_allocated(self.output_2)) self.build.complete_build_output(self.output_1, None) self.assertFalse(self.build.can_complete) self.build.complete_build_output(self.output_2, None) self.assertTrue(self.build.can_complete) self.build.complete_build(None) self.assertEqual(self.build.status, status.BuildStatus.COMPLETE) # the original BuildItem objects should have been deleted! self.assertEqual(BuildItem.objects.count(), 0) # New stock items should have been created! self.assertEqual(StockItem.objects.count(), 10) # This stock item has been depleted! with self.assertRaises(StockItem.DoesNotExist): StockItem.objects.get(pk=self.stock_1_1.pk) # This stock item has also been depleted with self.assertRaises(StockItem.DoesNotExist): StockItem.objects.get(pk=self.stock_2_1.pk) # And 10 new stock items created for the build output outputs = StockItem.objects.filter(build=self.build) self.assertEqual(outputs.count(), 2) for output in outputs: self.assertFalse(output.is_building) class AutoAllocationTests(BuildTestBase): """ Tests for auto allocating stock against a build order """ def setUp(self): super().setUp() # Add a "substitute" part for bom_item_2 alt_part = Part.objects.create( name="alt part", description="An alternative part!", component=True, ) BomItemSubstitute.objects.create( bom_item=self.bom_item_2, part=alt_part, ) StockItem.objects.create( part=alt_part, quantity=500, ) def test_auto_allocate(self): """ Run the 'auto-allocate' function. What do we expect to happen? There are two "untracked" parts: - sub_part_1 (quantity 5 per BOM = 50 required total) / 103 in stock (2 items) - sub_part_2 (quantity 3 per BOM = 30 required total) / 25 in stock (5 items) A "fully auto" allocation should allocate *all* of these stock items to the build """ # No build item allocations have been made against the build self.assertEqual(self.build.allocated_stock.count(), 0) self.assertFalse(self.build.are_untracked_parts_allocated()) # Stock is not interchangeable, nothing will happen self.build.auto_allocate_stock( interchangeable=False, substitutes=False, ) self.assertFalse(self.build.are_untracked_parts_allocated()) self.assertEqual(self.build.allocated_stock.count(), 0) self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_1)) self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2)) self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 50) self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 30) # This time we expect stock to be allocated! self.build.auto_allocate_stock( interchangeable=True, substitutes=False, ) self.assertFalse(self.build.are_untracked_parts_allocated()) self.assertEqual(self.build.allocated_stock.count(), 7) self.assertTrue(self.build.is_bom_item_allocated(self.bom_item_1)) self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2)) self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0) self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 5) # This time, allow substitue parts to be used! self.build.auto_allocate_stock( interchangeable=True, substitutes=True, ) # self.assertTrue(self.build.are_untracked_parts_allocated()) # self.assertEqual(self.build.allocated_stock.count(), 8) self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0) self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 0) self.assertTrue(self.build.is_bom_item_allocated(self.bom_item_1)) self.assertTrue(self.build.is_bom_item_allocated(self.bom_item_2)) def test_fully_auto(self): """ We should be able to auto-allocate against a build in a single go """ self.build.auto_allocate_stock( interchangeable=True, substitutes=True ) self.assertTrue(self.build.are_untracked_parts_allocated()) self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0) self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 0)