mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +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:
parent
7f12d55609
commit
5b0889d4c1
@ -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;
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user