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:
Oliver 2024-05-04 14:36:13 +10:00 committed by GitHub
parent 7f12d55609
commit 5b0889d4c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 142 additions and 36 deletions

View File

@ -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

View File

@ -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',
}

View File

@ -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()

View File

@ -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()

View File

@ -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):

View File

@ -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."""

View File

@ -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)

View File

@ -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;
}

View File

@ -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

View File

@ -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}
<Stack spacing="xs">
<LoadingOverlay visible={instanceQuery.isFetching} />
<PageDetail