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}