From 5b0889d4c1ab3d058b98789ed9ea4534847fd19e Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 4 May 2024 14:36:13 +1000 Subject: [PATCH] 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 --- .../InvenTree/InvenTree/api_version.py | 5 +- src/backend/InvenTree/InvenTree/settings.py | 2 +- src/backend/InvenTree/build/api.py | 31 +++++++++--- src/backend/InvenTree/build/models.py | 26 ++++++---- src/backend/InvenTree/build/serializers.py | 25 ++++++---- src/backend/InvenTree/build/tasks.py | 12 +++++ src/backend/InvenTree/build/test_api.py | 48 ++++++++++++++++++- .../templates/js/translated/build.js | 10 ++-- src/frontend/src/enums/ApiEndpoints.tsx | 2 + src/frontend/src/pages/build/BuildDetail.tsx | 17 ++++++- 10 files changed, 142 insertions(+), 36 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 31c3f08e8a19..5ffd55540021 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index 88d5af6ecbc3..2ef1fbc44c7c 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -284,7 +284,7 @@ }, 'IGNORE_REQUEST_PATTERNS': ['^(?!\/(api)?(plugin)?\/).*'], 'IGNORE_SQL_PATTERNS': [], - 'DISPLAY_DUPLICATES': 3, + 'DISPLAY_DUPLICATES': 1, 'RESPONSE_HEADER': 'X-Django-Query-Count', } diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index e6e6267fe240..09926ffb237d 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -103,15 +103,35 @@ def filter_has_project_code(self, queryset, name, value): 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 @@ def get_serializer(self, *args, **kwargs): 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() diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index 33f54db11337..b04c26c83c8f 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -552,11 +552,12 @@ def complete_build(self, user): 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 @@ def cancel_build(self, user, **kwargs): - 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() diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index 0ae06ef18fe0..8ce542cbf12e 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -15,14 +15,12 @@ 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 @@ def get_context_data(self): } 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 @@ def save(self): 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 Meta: 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): diff --git a/src/backend/InvenTree/build/tasks.py b/src/backend/InvenTree/build/tasks.py index 9464930ccd9b..b30a15f3d2d9 100644 --- a/src/backend/InvenTree/build/tasks.py +++ b/src/backend/InvenTree/build/tasks.py @@ -26,6 +26,18 @@ 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.""" diff --git a/src/backend/InvenTree/build/test_api.py b/src/backend/InvenTree/build/test_api.py index de8f0bbed87a..682a17e6598b 100644 --- a/src/backend/InvenTree/build/test_api.py +++ b/src/backend/InvenTree/build/test_api.py @@ -264,8 +264,35 @@ def test_complete(self): 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 @@ def test_cancel(self): 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) diff --git a/src/backend/InvenTree/templates/js/translated/build.js b/src/backend/InvenTree/templates/js/translated/build.js index 899866ae3566..e5dfa968cf18 100644 --- a/src/backend/InvenTree/templates/js/translated/build.js +++ b/src/backend/InvenTree/templates/js/translated/build.js @@ -974,11 +974,13 @@ function loadBuildOrderAllocationTable(table, options={}) { let ref = row.build_detail?.reference ?? row.build; let html = renderLink(ref, `/build/${row.build}/`); - html += `- ${row.build_detail.title}`; + if (row.build_detail) { + html += `- ${row.build_detail.title}`; - html += buildStatusDisplay(row.build_detail.status, { - classes: 'float-right', - }); + html += buildStatusDisplay(row.build_detail.status, { + classes: 'float-right', + }); + } return html; } diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index dd5fab30dacc..d92d152f8e86 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -52,8 +52,10 @@ export enum ApiEndpoints { // Build API endpoints build_order_list = 'build/', + build_order_cancel = 'build/:id/cancel/', build_order_attachment_list = 'build/attachment/', build_line_list = 'build/line/', + bom_list = 'bom/', // Part API endpoints diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx index fdc57276af36..3efea00a9f4d 100644 --- a/src/frontend/src/pages/build/BuildDetail.tsx +++ b/src/frontend/src/pages/build/BuildDetail.tsx @@ -301,6 +301,18 @@ export default function BuildDetail() { } }); + const cancelBuild = useCreateApiFormModal({ + url: apiUrl(ApiEndpoints.build_order_cancel, build.pk), + title: t`Cancel Build Order`, + fields: { + remove_allocated_stock: {}, + remove_incomplete_outputs: {} + }, + onFormSuccess: () => { + refreshInstance(); + } + }); + const duplicateBuild = useCreateApiFormModal({ url: ApiEndpoints.build_order_list, title: t`Add Build Order`, @@ -352,7 +364,9 @@ export default function BuildDetail() { hidden: !user.hasChangeRole(UserRoles.build) }), CancelItemAction({ - tooltip: t`Cancel order` + tooltip: t`Cancel order`, + onClick: () => cancelBuild.open() + // TODO: Hide if build cannot be cancelled }), DuplicateItemAction({ onClick: () => duplicateBuild.open(), @@ -379,6 +393,7 @@ export default function BuildDetail() { <> {editBuild.modal} {duplicateBuild.modal} + {cancelBuild.modal}