mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 13:15:43 +00:00 
			
		
		
		
	* Add new BuildLine model - Represents an instance of a BOM item against a BuildOrder * Create BuildLine instances automatically When a new Build is created, automatically generate new BuildLine items * Improve logic for handling exchange rate backends * logic fixes * Adds API endpoints Add list and detail API endpoints for new BuildLine model * update users/models.py - Add new model to roles definition * bulk-create on auto_allocate Save database hits by performing a bulk-create * Add skeleton data migration * Create BuildLines for existing orders * Working on building out BuildLine table * Adds link for "BuildLine" to "BuildItem" - A "BuildItem" will now be tracked against a BuildLine - Not tracked directly against a build - Not tracked directly against a BomItem - Add schema migration - Add data migration to update links * Adjust migration 0045 - bom_item and build fields are about to be removed - Set them to "nullable" so the data doesn't get removed * Remove old fields from BuildItem model - build fk - bom_item fk - A lot of other required changes too * Update BuildLine.bom_item field - Delete the BuildLine if the BomItem is removed - This is closer to current behaviour * Cleanup for Build model - tracked_bom_items -> tracked_line_items - untracked_bom_items -> tracked_bom_items - remove build.can_complete - move bom_item specific methods to the BuildLine model - Cleanup / consolidation * front-end work - Update javascript - Cleanup HTML templates * Add serializer annotation and filtering - Annotate 'allocated' quantity - Filter by allocated / trackable / optional / consumable * Make table sortable * Add buttons * Add callback for building new stock * Fix Part annotation * Adds callback to order parts * Allocation works again * template cleanup * Fix allocate / unallocate actions - Also turns out "unallocate" is not a word.. * auto-allocate works again * Fix call to build.is_over_allocated * Refactoring updates * Bump API version * Cleaner implementation of allocation sub-table * Fix rendering in build output table * Improvements to StockItem list API - Refactor very old code - Add option to include test results to queryset * Add TODO for later me * Fix for serializers.py * Working on cleaner implementation of build output table * Add function to determine if a single output is fully allocated * Updates to build.js - Button callbacks - Table rendering * Revert previous changes to build.serializers.py * Fix for forms.js * Rearrange code in build.js * Rebuild "allocated lines" for output table * Fix allocation calculation * Show or hide column for tracked parts * Improve debug messages * Refactor "loadBuildLineTable" - Allow it to also be used as output sub-table * Refactor "completed tests" column * Remove old javascript - Cleans up a *lot* of crusty old code * Annotate the available stock quantity to BuildLine serializer - Similar pattern to BomItem serializer - Needs refactoring in the future * Update available column * Fix build allocation table - Bug fix - Make pretty * linting fixes * Allow sorting by available stock * Tweak for "required tests" column * Bug fix for completing a build output * Fix for consumable stock * Fix for trim_allocated_stock * Fix for creating new build * Migration fix - Ensure initial django_q migrations are applied - Why on earth is this failing now? * Catch exception * Update for exception handling * Update migrations - Ensure inventreesetting is added * Catch all exceptions when getting default currency code * Bug fix for currency exchange rates update * Working on unit tests * Unit test fixes * More work on unit tests * Use bulk_create in unit test * Update required quantity when a BuildOrder is saved * Tweak overage display in BOM table * Fix icon in BOM table * Fix spelling error * More unit test fixes * Build reports - Add line_items - Update docs - Cleanup * Reimplement is_partially_allocated method * Update docs about overage * Unit testing for data migration * Add "required_for_build_orders" annotation - Makes API query *much* faster now - remove old "required_parts_to_complete_build" method - Cleanup part API filter code * Adjust order of fixture loading * Fix unit test * Prevent "schedule_pricing_update" in unit tests - Should cut down on DB hits significantly * Unit test updates * Improvements for unit test - Don't hard-code pk values - postgresql no likey * Better unit test
		
			
				
	
	
		
			721 lines
		
	
	
		
			22 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			721 lines
		
	
	
		
			22 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| """Unit tests for the 'build' models"""
 | |
| 
 | |
| from datetime import datetime, timedelta
 | |
| 
 | |
| from django.test import TestCase
 | |
| 
 | |
| from django.contrib.auth import get_user_model
 | |
| from django.contrib.auth.models import Group
 | |
| from django.core.exceptions import ValidationError
 | |
| from django.db.models import Sum
 | |
| 
 | |
| from InvenTree import status_codes as status
 | |
| 
 | |
| import common.models
 | |
| import build.tasks
 | |
| from build.models import Build, BuildItem, BuildLine, generate_next_build_reference
 | |
| from part.models import Part, BomItem, BomItemSubstitute
 | |
| from stock.models import StockItem
 | |
| from users.models import Owner
 | |
| 
 | |
| import logging
 | |
| logger = logging.getLogger('inventree')
 | |
| 
 | |
| 
 | |
| class BuildTestBase(TestCase):
 | |
|     """Run some tests to ensure that the Build model is working properly."""
 | |
| 
 | |
|     fixtures = [
 | |
|         'users',
 | |
|     ]
 | |
| 
 | |
|     @classmethod
 | |
|     def setUpTestData(cls):
 | |
|         """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
 | |
| 
 | |
|         """
 | |
| 
 | |
|         super().setUpTestData()
 | |
| 
 | |
|         # Create a base "Part"
 | |
|         cls.assembly = Part.objects.create(
 | |
|             name="An assembled part",
 | |
|             description="Why does it matter what my description is?",
 | |
|             assembly=True,
 | |
|             trackable=True,
 | |
|         )
 | |
| 
 | |
|         cls.sub_part_1 = Part.objects.create(
 | |
|             name="Widget A",
 | |
|             description="A widget",
 | |
|             component=True
 | |
|         )
 | |
| 
 | |
|         cls.sub_part_2 = Part.objects.create(
 | |
|             name="Widget B",
 | |
|             description="A widget",
 | |
|             component=True
 | |
|         )
 | |
| 
 | |
|         cls.sub_part_3 = Part.objects.create(
 | |
|             name="Widget C",
 | |
|             description="A widget",
 | |
|             component=True,
 | |
|             trackable=True
 | |
|         )
 | |
| 
 | |
|         # Create BOM item links for the parts
 | |
|         cls.bom_item_1 = BomItem.objects.create(
 | |
|             part=cls.assembly,
 | |
|             sub_part=cls.sub_part_1,
 | |
|             quantity=5
 | |
|         )
 | |
| 
 | |
|         cls.bom_item_2 = BomItem.objects.create(
 | |
|             part=cls.assembly,
 | |
|             sub_part=cls.sub_part_2,
 | |
|             quantity=3,
 | |
|             optional=True
 | |
|         )
 | |
| 
 | |
|         # sub_part_3 is trackable!
 | |
|         cls.bom_item_3 = BomItem.objects.create(
 | |
|             part=cls.assembly,
 | |
|             sub_part=cls.sub_part_3,
 | |
|             quantity=2
 | |
|         )
 | |
| 
 | |
|         ref = generate_next_build_reference()
 | |
| 
 | |
|         # Create a "Build" object to make 10x objects
 | |
|         cls.build = Build.objects.create(
 | |
|             reference=ref,
 | |
|             title="This is a build",
 | |
|             part=cls.assembly,
 | |
|             quantity=10,
 | |
|             issued_by=get_user_model().objects.get(pk=1),
 | |
|         )
 | |
| 
 | |
|         # Create some BuildLine items we can use later on
 | |
|         cls.line_1 = BuildLine.objects.get(build=cls.build, bom_item=cls.bom_item_1)
 | |
|         cls.line_2 = BuildLine.objects.get(build=cls.build, bom_item=cls.bom_item_2)
 | |
|         cls.line_3 = BuildLine.objects.get(build=cls.build, bom_item=cls.bom_item_3)
 | |
| 
 | |
|         # Create some build output (StockItem) objects
 | |
|         cls.output_1 = StockItem.objects.create(
 | |
|             part=cls.assembly,
 | |
|             quantity=3,
 | |
|             is_building=True,
 | |
|             build=cls.build
 | |
|         )
 | |
| 
 | |
|         cls.output_2 = StockItem.objects.create(
 | |
|             part=cls.assembly,
 | |
|             quantity=7,
 | |
|             is_building=True,
 | |
|             build=cls.build,
 | |
|         )
 | |
| 
 | |
|         # Create some stock items to assign to the build
 | |
|         cls.stock_1_1 = StockItem.objects.create(part=cls.sub_part_1, quantity=3)
 | |
|         cls.stock_1_2 = StockItem.objects.create(part=cls.sub_part_1, quantity=100)
 | |
| 
 | |
|         cls.stock_2_1 = StockItem.objects.create(part=cls.sub_part_2, quantity=5)
 | |
|         cls.stock_2_2 = StockItem.objects.create(part=cls.sub_part_2, quantity=5)
 | |
|         cls.stock_2_3 = StockItem.objects.create(part=cls.sub_part_2, quantity=5)
 | |
|         cls.stock_2_4 = StockItem.objects.create(part=cls.sub_part_2, quantity=5)
 | |
|         cls.stock_2_5 = StockItem.objects.create(part=cls.sub_part_2, quantity=5)
 | |
| 
 | |
|         cls.stock_3_1 = StockItem.objects.create(part=cls.sub_part_3, quantity=1000)
 | |
| 
 | |
| 
 | |
| class BuildTest(BuildTestBase):
 | |
|     """Unit testing class for the Build model"""
 | |
| 
 | |
|     def test_ref_int(self):
 | |
|         """Test the "integer reference" field used for natural sorting"""
 | |
| 
 | |
|         common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref}-???', change_user=None)
 | |
| 
 | |
|         refs = {
 | |
|             'BO-123-456': 123,
 | |
|             'BO-456-123': 456,
 | |
|             'BO-999-ABC': 999,
 | |
|             'BO-123ABC-ABC': 123,
 | |
|             'BO-ABC123-ABC': 123,
 | |
|         }
 | |
| 
 | |
|         for ref, ref_int in refs.items():
 | |
|             build = Build(
 | |
|                 reference=ref,
 | |
|                 quantity=1,
 | |
|                 part=self.assembly,
 | |
|                 title='Making some parts',
 | |
|             )
 | |
| 
 | |
|             self.assertEqual(build.reference_int, 0)
 | |
|             build.save()
 | |
|             self.assertEqual(build.reference_int, ref_int)
 | |
| 
 | |
|     def test_ref_validation(self):
 | |
|         """Test that the reference field validation works as expected"""
 | |
| 
 | |
|         # Default reference pattern = 'BO-{ref:04d}
 | |
| 
 | |
|         # These patterns should fail
 | |
|         for ref in [
 | |
|             'BO-1234x',
 | |
|             'BO1234',
 | |
|             'OB-1234',
 | |
|             'BO--1234'
 | |
|         ]:
 | |
|             with self.assertRaises(ValidationError):
 | |
|                 Build.objects.create(
 | |
|                     part=self.assembly,
 | |
|                     quantity=10,
 | |
|                     reference=ref,
 | |
|                     title='Invalid reference',
 | |
|                 )
 | |
| 
 | |
|         for ref in [
 | |
|             'BO-1234',
 | |
|             'BO-9999',
 | |
|             'BO-123'
 | |
|         ]:
 | |
|             Build.objects.create(
 | |
|                 part=self.assembly,
 | |
|                 quantity=10,
 | |
|                 reference=ref,
 | |
|                 title='Valid reference',
 | |
|             )
 | |
| 
 | |
|         # Try a new validator pattern
 | |
|         common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', '{ref}-BO', change_user=None)
 | |
| 
 | |
|         for ref in [
 | |
|             '1234-BO',
 | |
|             '9999-BO'
 | |
|         ]:
 | |
|             Build.objects.create(
 | |
|                 part=self.assembly,
 | |
|                 quantity=10,
 | |
|                 reference=ref,
 | |
|                 title='Valid reference',
 | |
|             )
 | |
| 
 | |
|     def test_next_ref(self):
 | |
|         """Test that the next reference is automatically generated"""
 | |
| 
 | |
|         common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'XYZ-{ref:06d}', change_user=None)
 | |
| 
 | |
|         build = Build.objects.create(
 | |
|             part=self.assembly,
 | |
|             quantity=5,
 | |
|             reference='XYZ-987',
 | |
|             title='Some thing',
 | |
|         )
 | |
| 
 | |
|         self.assertEqual(build.reference_int, 987)
 | |
| 
 | |
|         # Now create one *without* specifying the reference
 | |
|         build = Build.objects.create(
 | |
|             part=self.assembly,
 | |
|             quantity=1,
 | |
|             title='Some new title',
 | |
|         )
 | |
| 
 | |
|         self.assertEqual(build.reference, 'XYZ-000988')
 | |
|         self.assertEqual(build.reference_int, 988)
 | |
| 
 | |
|     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.line_1.is_fully_allocated())
 | |
|         self.assertFalse(self.line_2.is_overallocated())
 | |
| 
 | |
|         self.assertEqual(self.line_1.allocated_quantity(), 0)
 | |
| 
 | |
|         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_line=self.line_2, 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_line=self.line_1, quantity=9999999)
 | |
| 
 | |
|         with self.assertRaises(ValidationError):
 | |
|             b.clean()
 | |
| 
 | |
|         # Negative stock? Not on my watch!
 | |
|         b = BuildItem(stock_item=self.stock_1_1, build_line=self.line_1, 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_line=self.line_1, install_into=self.output_1, quantity=10)
 | |
|         b.save()
 | |
| 
 | |
|     def test_duplicate_bom_line(self):
 | |
|         """Try to add a duplicate BOM item - it should be allowed"""
 | |
| 
 | |
|         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}
 | |
|         """
 | |
| 
 | |
|         items_to_create = []
 | |
| 
 | |
|         for item, quantity in allocations.items():
 | |
| 
 | |
|             # Find an appropriate BuildLine to allocate against
 | |
|             line = BuildLine.objects.filter(
 | |
|                 build=self.build,
 | |
|                 bom_item__sub_part=item.part
 | |
|             ).first()
 | |
| 
 | |
|             items_to_create.append(BuildItem(
 | |
|                 build_line=line,
 | |
|                 stock_item=item,
 | |
|                 quantity=quantity,
 | |
|                 install_into=output
 | |
|             ))
 | |
| 
 | |
|         BuildItem.objects.bulk_create(items_to_create)
 | |
| 
 | |
|     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_output_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_output_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_output_fully_allocated(None))
 | |
| 
 | |
|         # Find lines which are *not* fully allocated
 | |
|         unallocated = self.build.unallocated_lines()
 | |
| 
 | |
|         self.assertEqual(len(unallocated), 3)
 | |
| 
 | |
|         self.allocate_stock(
 | |
|             None,
 | |
|             {
 | |
|                 self.stock_1_2: 100,
 | |
|             }
 | |
|         )
 | |
| 
 | |
|         self.assertFalse(self.build.is_fully_allocated(None))
 | |
| 
 | |
|         unallocated = self.build.unallocated_lines()
 | |
| 
 | |
|         self.assertEqual(len(unallocated), 2)
 | |
| 
 | |
|         self.build.deallocate_stock()
 | |
| 
 | |
|         unallocated = self.build.unallocated_lines(None)
 | |
| 
 | |
|         self.assertEqual(len(unallocated), 3)
 | |
| 
 | |
|         self.assertFalse(self.build.is_fully_allocated(tracked=False))
 | |
| 
 | |
|         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.is_fully_allocated(tracked=False))
 | |
| 
 | |
|     def test_overallocation_and_trim(self):
 | |
|         """Test overallocation of stock and trim function"""
 | |
| 
 | |
|         # Fully allocate tracked stock (not eligible for trimming)
 | |
|         self.allocate_stock(
 | |
|             self.output_1,
 | |
|             {
 | |
|                 self.stock_3_1: 6,
 | |
|             }
 | |
|         )
 | |
|         self.allocate_stock(
 | |
|             self.output_2,
 | |
|             {
 | |
|                 self.stock_3_1: 14,
 | |
|             }
 | |
|         )
 | |
|         # Fully allocate part 1 (should be left alone)
 | |
|         self.allocate_stock(
 | |
|             None,
 | |
|             {
 | |
|                 self.stock_1_1: 3,
 | |
|                 self.stock_1_2: 47,
 | |
|             }
 | |
|         )
 | |
| 
 | |
|         extra_2_1 = StockItem.objects.create(part=self.sub_part_2, quantity=6)
 | |
|         extra_2_2 = StockItem.objects.create(part=self.sub_part_2, quantity=4)
 | |
| 
 | |
|         # Overallocate part 2 (30 needed)
 | |
|         self.allocate_stock(
 | |
|             None,
 | |
|             {
 | |
|                 self.stock_2_1: 5,
 | |
|                 self.stock_2_2: 5,
 | |
|                 self.stock_2_3: 5,
 | |
|                 self.stock_2_4: 5,
 | |
|                 self.stock_2_5: 5,  # 25
 | |
|                 extra_2_1: 6,       # 31
 | |
|                 extra_2_2: 4,       # 35
 | |
|             }
 | |
|         )
 | |
| 
 | |
|         self.assertTrue(self.build.is_overallocated())
 | |
| 
 | |
|         self.build.trim_allocated_stock()
 | |
|         self.assertFalse(self.build.is_overallocated())
 | |
| 
 | |
|         self.build.complete_build_output(self.output_1, None)
 | |
|         self.build.complete_build_output(self.output_2, None)
 | |
|         self.assertTrue(self.build.can_complete)
 | |
| 
 | |
|         n = StockItem.objects.filter(consumed_by=self.build).count()
 | |
| 
 | |
|         self.build.complete_build(None)
 | |
| 
 | |
|         self.assertEqual(self.build.status, status.BuildStatus.COMPLETE)
 | |
| 
 | |
|         # Check stock items are in expected state.
 | |
|         self.assertEqual(StockItem.objects.get(pk=self.stock_1_2.pk).quantity, 53)
 | |
| 
 | |
|         # Total stock quantity has not been decreased
 | |
|         items = StockItem.objects.filter(part=self.sub_part_2)
 | |
|         self.assertEqual(items.aggregate(Sum('quantity'))['quantity__sum'], 35)
 | |
| 
 | |
|         # However, the "available" stock quantity has been decreased
 | |
|         self.assertEqual(items.filter(consumed_by=None).aggregate(Sum('quantity'))['quantity__sum'], 5)
 | |
| 
 | |
|         # And the "consumed_by" quantity has been increased
 | |
|         self.assertEqual(items.filter(consumed_by=self.build).aggregate(Sum('quantity'))['quantity__sum'], 30)
 | |
| 
 | |
|         self.assertEqual(StockItem.objects.get(pk=self.stock_3_1.pk).quantity, 980)
 | |
| 
 | |
|         # Check that the "consumed_by" item count has increased
 | |
|         self.assertEqual(StockItem.objects.filter(consumed_by=self.build).count(), n + 8)
 | |
| 
 | |
|     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(), 13)
 | |
| 
 | |
|         # This stock item has been marked as "consumed"
 | |
|         item = StockItem.objects.get(pk=self.stock_1_1.pk)
 | |
|         self.assertIsNotNone(item.consumed_by)
 | |
|         self.assertFalse(item.in_stock)
 | |
| 
 | |
|         # 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)
 | |
| 
 | |
|     def test_overdue_notification(self):
 | |
|         """Test sending of notifications when a build order is overdue."""
 | |
| 
 | |
|         self.build.target_date = datetime.now().date() - timedelta(days=1)
 | |
|         self.build.save()
 | |
| 
 | |
|         # Check for overdue orders
 | |
|         build.tasks.check_overdue_build_orders()
 | |
| 
 | |
|         message = common.models.NotificationMessage.objects.get(
 | |
|             category='build.overdue_build_order',
 | |
|             user__id=1,
 | |
|         )
 | |
| 
 | |
|         self.assertEqual(message.name, 'Overdue Build Order')
 | |
| 
 | |
|     def test_new_build_notification(self):
 | |
|         """Test that a notification is sent when a new build is created"""
 | |
| 
 | |
|         Build.objects.create(
 | |
|             reference='BO-9999',
 | |
|             title='Some new build',
 | |
|             part=self.assembly,
 | |
|             quantity=5,
 | |
|             issued_by=get_user_model().objects.get(pk=2),
 | |
|             responsible=Owner.create(obj=Group.objects.get(pk=3))
 | |
|         )
 | |
| 
 | |
|         # Two notifications should have been sent
 | |
|         messages = common.models.NotificationMessage.objects.filter(
 | |
|             category='build.new_build',
 | |
|         )
 | |
| 
 | |
|         self.assertEqual(messages.count(), 1)
 | |
| 
 | |
|         self.assertFalse(messages.filter(user__pk=2).exists())
 | |
| 
 | |
|         # Inactive users do not receive notifications
 | |
|         self.assertFalse(messages.filter(user__pk=3).exists())
 | |
| 
 | |
|         self.assertTrue(messages.filter(user__pk=4).exists())
 | |
| 
 | |
|     def test_metadata(self):
 | |
|         """Unit tests for the metadata field."""
 | |
| 
 | |
|         # Make sure a BuildItem exists before trying to run this test
 | |
|         b = BuildItem(stock_item=self.stock_1_2, build_line=self.line_1, install_into=self.output_1, quantity=10)
 | |
|         b.save()
 | |
| 
 | |
|         for model in [Build, BuildItem]:
 | |
|             p = model.objects.first()
 | |
|             self.assertEqual(len(p.metadata.keys()), 0)
 | |
| 
 | |
|             self.assertIsNone(p.get_metadata('test'))
 | |
|             self.assertEqual(p.get_metadata('test', backup_value=123), 123)
 | |
| 
 | |
|             # Test update via the set_metadata() method
 | |
|             p.set_metadata('test', 3)
 | |
|             self.assertEqual(p.get_metadata('test'), 3)
 | |
| 
 | |
|             for k in ['apple', 'banana', 'carrot', 'carrot', 'banana']:
 | |
|                 p.set_metadata(k, k)
 | |
| 
 | |
|             self.assertEqual(len(p.metadata.keys()), 4)
 | |
| 
 | |
| 
 | |
| class AutoAllocationTests(BuildTestBase):
 | |
|     """Tests for auto allocating stock against a build order"""
 | |
| 
 | |
|     def setUp(self):
 | |
|         """Init routines for this unit test class"""
 | |
|         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.is_fully_allocated(tracked=False))
 | |
| 
 | |
|         # Stock is not interchangeable, nothing will happen
 | |
|         self.build.auto_allocate_stock(
 | |
|             interchangeable=False,
 | |
|             substitutes=False,
 | |
|         )
 | |
| 
 | |
|         self.assertFalse(self.build.is_fully_allocated(tracked=False))
 | |
| 
 | |
|         self.assertEqual(self.build.allocated_stock.count(), 0)
 | |
| 
 | |
|         self.assertFalse(self.line_1.is_fully_allocated())
 | |
|         self.assertFalse(self.line_2.is_fully_allocated())
 | |
| 
 | |
|         self.assertEqual(self.line_1.unallocated_quantity(), 50)
 | |
|         self.assertEqual(self.line_2.unallocated_quantity(), 30)
 | |
| 
 | |
|         # This time we expect stock to be allocated!
 | |
|         self.build.auto_allocate_stock(
 | |
|             interchangeable=True,
 | |
|             substitutes=False,
 | |
|             optional_items=True,
 | |
|         )
 | |
| 
 | |
|         self.assertFalse(self.build.is_fully_allocated(tracked=False))
 | |
| 
 | |
|         self.assertEqual(self.build.allocated_stock.count(), 7)
 | |
| 
 | |
|         self.assertTrue(self.line_1.is_fully_allocated())
 | |
|         self.assertFalse(self.line_2.is_fully_allocated())
 | |
| 
 | |
|         self.assertEqual(self.line_1.unallocated_quantity(), 0)
 | |
|         self.assertEqual(self.line_2.unallocated_quantity(), 5)
 | |
| 
 | |
|         # This time, allow substitute parts to be used!
 | |
|         self.build.auto_allocate_stock(
 | |
|             interchangeable=True,
 | |
|             substitutes=True,
 | |
|         )
 | |
| 
 | |
|         self.assertEqual(self.line_1.unallocated_quantity(), 0)
 | |
|         self.assertEqual(self.line_2.unallocated_quantity(), 5)
 | |
| 
 | |
|         self.assertTrue(self.line_1.is_fully_allocated())
 | |
|         self.assertFalse(self.line_2.is_fully_allocated())
 | |
| 
 | |
|     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,
 | |
|             optional_items=True,
 | |
|         )
 | |
| 
 | |
|         self.assertTrue(self.build.is_fully_allocated(tracked=False))
 | |
| 
 | |
|         self.assertEqual(self.line_1.unallocated_quantity(), 0)
 | |
|         self.assertEqual(self.line_2.unallocated_quantity(), 0)
 |