mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 13:15:43 +00:00 
			
		
		
		
	Build order cancel (#7153)
* Fix BuildCancelSerializer * Change name of serializer field * Perform bulk_delete operation * Implement BuildCancel in PUI * Handle null build * Bump API version * Improve query efficiency for build endpoints * Offload allocation cleanup in cancel task * Handle exception if offloading fails * Offload auto-allocation of build order stock * Add unit test for cancelling build order *and* consuming stock
This commit is contained in:
		| @@ -1,11 +1,14 @@ | ||||
| """InvenTree API version information.""" | ||||
|  | ||||
| # InvenTree API version | ||||
| INVENTREE_API_VERSION = 194 | ||||
| INVENTREE_API_VERSION = 195 | ||||
| """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" | ||||
|  | ||||
| INVENTREE_API_TEXT = """ | ||||
|  | ||||
| v195 - 2024-05-03 : https://github.com/inventree/InvenTree/pull/7153 | ||||
|     - Fixes bug in BuildOrderCancel API endpoint | ||||
|  | ||||
| v194 - 2024-05-01 : https://github.com/inventree/InvenTree/pull/7147 | ||||
|     -  Adds field description to the currency_exchange_retrieve API call | ||||
|  | ||||
|   | ||||
| @@ -284,7 +284,7 @@ QUERYCOUNT = { | ||||
|     }, | ||||
|     'IGNORE_REQUEST_PATTERNS': ['^(?!\/(api)?(plugin)?\/).*'], | ||||
|     'IGNORE_SQL_PATTERNS': [], | ||||
|     'DISPLAY_DUPLICATES': 3, | ||||
|     'DISPLAY_DUPLICATES': 1, | ||||
|     'RESPONSE_HEADER': 'X-Django-Query-Count', | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -103,15 +103,35 @@ class BuildFilter(rest_filters.FilterSet): | ||||
|         return queryset.filter(project_code=None) | ||||
|  | ||||
|  | ||||
| class BuildList(APIDownloadMixin, ListCreateAPI): | ||||
| class BuildMixin: | ||||
|     """Mixin class for Build API endpoints.""" | ||||
|  | ||||
|     queryset = Build.objects.all() | ||||
|     serializer_class = build.serializers.BuildSerializer | ||||
|  | ||||
|     def get_queryset(self): | ||||
|         """Return the queryset for the Build API endpoints.""" | ||||
|         queryset = super().get_queryset() | ||||
|  | ||||
|         queryset = queryset.prefetch_related( | ||||
|             'responsible', | ||||
|             'issued_by', | ||||
|             'build_lines', | ||||
|             'build_lines__bom_item', | ||||
|             'build_lines__build', | ||||
|             'part', | ||||
|         ) | ||||
|  | ||||
|         return queryset | ||||
|  | ||||
|  | ||||
| class BuildList(APIDownloadMixin, BuildMixin, ListCreateAPI): | ||||
|     """API endpoint for accessing a list of Build objects. | ||||
|  | ||||
|     - GET: Return list of objects (with filters) | ||||
|     - POST: Create a new Build object | ||||
|     """ | ||||
|  | ||||
|     queryset = Build.objects.all() | ||||
|     serializer_class = build.serializers.BuildSerializer | ||||
|     filterset_class = BuildFilter | ||||
|  | ||||
|     filter_backends = SEARCH_ORDER_FILTER_ALIAS | ||||
| @@ -223,12 +243,9 @@ class BuildList(APIDownloadMixin, ListCreateAPI): | ||||
|         return self.serializer_class(*args, **kwargs) | ||||
|  | ||||
|  | ||||
| class BuildDetail(RetrieveUpdateDestroyAPI): | ||||
| class BuildDetail(BuildMixin, RetrieveUpdateDestroyAPI): | ||||
|     """API endpoint for detail view of a Build object.""" | ||||
|  | ||||
|     queryset = Build.objects.all() | ||||
|     serializer_class = build.serializers.BuildSerializer | ||||
|  | ||||
|     def destroy(self, request, *args, **kwargs): | ||||
|         """Only allow deletion of a BuildOrder if the build status is CANCELLED""" | ||||
|         build = self.get_object() | ||||
|   | ||||
| @@ -552,11 +552,12 @@ class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNo | ||||
|         self.save() | ||||
|  | ||||
|         # Offload task to complete build allocations | ||||
|         InvenTree.tasks.offload_task( | ||||
|         if not InvenTree.tasks.offload_task( | ||||
|             build.tasks.complete_build_allocations, | ||||
|             self.pk, | ||||
|             user.pk if user else None | ||||
|         ) | ||||
|         ): | ||||
|             raise ValidationError(_("Failed to offload task to complete build allocations")) | ||||
|  | ||||
|         # Register an event | ||||
|         trigger_event('build.completed', id=self.pk) | ||||
| @@ -608,24 +609,29 @@ class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNo | ||||
|         - Set build status to CANCELLED | ||||
|         - Save the Build object | ||||
|         """ | ||||
|  | ||||
|         import build.tasks | ||||
|  | ||||
|         remove_allocated_stock = kwargs.get('remove_allocated_stock', False) | ||||
|         remove_incomplete_outputs = kwargs.get('remove_incomplete_outputs', False) | ||||
|  | ||||
|         # Find all BuildItem objects associated with this Build | ||||
|         items = self.allocated_stock | ||||
|  | ||||
|         if remove_allocated_stock: | ||||
|             for item in items: | ||||
|                 item.complete_allocation(user) | ||||
|             # Offload task to remove allocated stock | ||||
|             if not InvenTree.tasks.offload_task( | ||||
|                 build.tasks.complete_build_allocations, | ||||
|                 self.pk, | ||||
|                 user.pk if user else None | ||||
|             ): | ||||
|                 raise ValidationError(_("Failed to offload task to complete build allocations")) | ||||
|  | ||||
|         items.delete() | ||||
|         else: | ||||
|             self.allocated_stock.all().delete() | ||||
|  | ||||
|         # Remove incomplete outputs (if required) | ||||
|         if remove_incomplete_outputs: | ||||
|             outputs = self.build_outputs.filter(is_building=True) | ||||
|  | ||||
|             for output in outputs: | ||||
|                 output.delete() | ||||
|             outputs.delete() | ||||
|  | ||||
|         # Date of 'completion' is the date the build was cancelled | ||||
|         self.completion_date = InvenTree.helpers.current_date() | ||||
|   | ||||
| @@ -15,14 +15,12 @@ from django.db.models.functions import Coalesce | ||||
| from rest_framework import serializers | ||||
| from rest_framework.serializers import ValidationError | ||||
|  | ||||
| from sql_util.utils import SubquerySum | ||||
|  | ||||
| from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer | ||||
| from InvenTree.serializers import UserSerializer | ||||
|  | ||||
| import InvenTree.helpers | ||||
| from InvenTree.serializers import InvenTreeDecimalField | ||||
| from InvenTree.status_codes import BuildStatusGroups, StockStatus | ||||
| from InvenTree.status_codes import StockStatus | ||||
|  | ||||
| from stock.models import generate_batch_code, StockItem, StockLocation | ||||
| from stock.serializers import StockItemSerializerBrief, LocationSerializer | ||||
| @@ -589,8 +587,8 @@ class BuildCancelSerializer(serializers.Serializer): | ||||
|         } | ||||
|  | ||||
|     remove_allocated_stock = serializers.BooleanField( | ||||
|         label=_('Remove Allocated Stock'), | ||||
|         help_text=_('Subtract any stock which has already been allocated to this build'), | ||||
|         label=_('Consume Allocated Stock'), | ||||
|         help_text=_('Consume any stock which has already been allocated to this build'), | ||||
|         required=False, | ||||
|         default=False, | ||||
|     ) | ||||
| @@ -611,7 +609,7 @@ class BuildCancelSerializer(serializers.Serializer): | ||||
|  | ||||
|         build.cancel_build( | ||||
|             request.user, | ||||
|             remove_allocated_stock=data.get('remove_unallocated_stock', False), | ||||
|             remove_allocated_stock=data.get('remove_allocated_stock', False), | ||||
|             remove_incomplete_outputs=data.get('remove_incomplete_outputs', False), | ||||
|         ) | ||||
|  | ||||
| @@ -994,17 +992,24 @@ class BuildAutoAllocationSerializer(serializers.Serializer): | ||||
|  | ||||
|     def save(self): | ||||
|         """Perform the auto-allocation step""" | ||||
|  | ||||
|         import build.tasks | ||||
|         import InvenTree.tasks | ||||
|  | ||||
|         data = self.validated_data | ||||
|  | ||||
|         build = self.context['build'] | ||||
|         build_order = self.context['build'] | ||||
|  | ||||
|         build.auto_allocate_stock( | ||||
|         if not InvenTree.tasks.offload_task( | ||||
|             build.tasks.auto_allocate_build, | ||||
|             build_order.pk, | ||||
|             location=data.get('location', None), | ||||
|             exclude_location=data.get('exclude_location', None), | ||||
|             interchangeable=data['interchangeable'], | ||||
|             substitutes=data['substitutes'], | ||||
|             optional_items=data['optional_items'], | ||||
|         ) | ||||
|             optional_items=data['optional_items'] | ||||
|         ): | ||||
|             raise ValidationError(_("Failed to start auto-allocation task")) | ||||
|  | ||||
|  | ||||
| class BuildItemSerializer(InvenTreeModelSerializer): | ||||
|   | ||||
| @@ -26,6 +26,18 @@ import part.models as part_models | ||||
| logger = logging.getLogger('inventree') | ||||
|  | ||||
|  | ||||
| def auto_allocate_build(build_id: int, **kwargs): | ||||
|     """Run auto-allocation for a specified BuildOrder.""" | ||||
|  | ||||
|     build_order = build.models.Build.objects.filter(pk=build_id).first() | ||||
|  | ||||
|     if not build_order: | ||||
|         logger.warning("Could not auto-allocate BuildOrder <%s> - BuildOrder does not exist", build_id) | ||||
|         return | ||||
|  | ||||
|     build_order.auto_allocate_stock(**kwargs) | ||||
|  | ||||
|  | ||||
| def complete_build_allocations(build_id: int, user_id: int): | ||||
|     """Complete build allocations for a specified BuildOrder.""" | ||||
|  | ||||
|   | ||||
| @@ -264,8 +264,35 @@ class BuildTest(BuildAPITest): | ||||
|         self.assertTrue(self.build.is_complete) | ||||
|  | ||||
|     def test_cancel(self): | ||||
|         """Test that we can cancel a BuildOrder via the API.""" | ||||
|         bo = Build.objects.get(pk=1) | ||||
|         """Test that we can cancel a BuildOrder via the API. | ||||
|  | ||||
|         - First test that all stock is returned to stock | ||||
|         - Second test that stock is consumed by the build order | ||||
|         """ | ||||
|  | ||||
|         def make_new_build(ref): | ||||
|             """Make a new build order, and allocate stock to it.""" | ||||
|  | ||||
|             data = self.post( | ||||
|                 reverse('api-build-list'), | ||||
|                 { | ||||
|                     'part': 100, | ||||
|                     'quantity': 10, | ||||
|                     'title': 'Test build', | ||||
|                     'reference': ref, | ||||
|                 }, | ||||
|                 expected_code=201 | ||||
|             ).data | ||||
|  | ||||
|             build = Build.objects.get(pk=data['pk']) | ||||
|  | ||||
|             build.auto_allocate_stock() | ||||
|  | ||||
|             self.assertGreater(build.build_lines.count(), 0) | ||||
|  | ||||
|             return build | ||||
|  | ||||
|         bo = make_new_build('BO-12345') | ||||
|  | ||||
|         url = reverse('api-build-cancel', kwargs={'pk': bo.pk}) | ||||
|  | ||||
| @@ -277,6 +304,23 @@ class BuildTest(BuildAPITest): | ||||
|  | ||||
|         self.assertEqual(bo.status, BuildStatus.CANCELLED) | ||||
|  | ||||
|         # No items were "consumed" by this build | ||||
|         self.assertEqual(bo.consumed_stock.count(), 0) | ||||
|  | ||||
|         # Make another build, this time we will *consume* the allocated stock | ||||
|         bo = make_new_build('BO-12346') | ||||
|  | ||||
|         url = reverse('api-build-cancel', kwargs={'pk': bo.pk}) | ||||
|  | ||||
|         self.post(url, {'remove_allocated_stock': True}, expected_code=201) | ||||
|  | ||||
|         bo.refresh_from_db() | ||||
|  | ||||
|         self.assertEqual(bo.status, BuildStatus.CANCELLED) | ||||
|  | ||||
|         # This time, there should be *consumed* stock | ||||
|         self.assertGreater(bo.consumed_stock.count(), 0) | ||||
|  | ||||
|     def test_delete(self): | ||||
|         """Test that we can delete a BuildOrder via the API""" | ||||
|         bo = Build.objects.get(pk=1) | ||||
|   | ||||
| @@ -974,11 +974,13 @@ function loadBuildOrderAllocationTable(table, options={}) { | ||||
|                     let ref = row.build_detail?.reference ?? row.build; | ||||
|                     let html = renderLink(ref, `/build/${row.build}/`); | ||||
|  | ||||
|                     html += `- <small>${row.build_detail.title}</small>`; | ||||
|                     if (row.build_detail) { | ||||
|                         html += `- <small>${row.build_detail.title}</small>`; | ||||
|  | ||||
|                     html += buildStatusDisplay(row.build_detail.status, { | ||||
|                         classes: 'float-right', | ||||
|                     }); | ||||
|                         html += buildStatusDisplay(row.build_detail.status, { | ||||
|                             classes: 'float-right', | ||||
|                         }); | ||||
|                     } | ||||
|  | ||||
|                     return html; | ||||
|                 } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user