2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 19:46:46 +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:
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 information."""
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ 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 v194 - 2024-05-01 : https://github.com/inventree/InvenTree/pull/7147
- Adds field description to the currency_exchange_retrieve API call - Adds field description to the currency_exchange_retrieve API call

View File

@ -284,7 +284,7 @@ QUERYCOUNT = {
}, },
'IGNORE_REQUEST_PATTERNS': ['^(?!\/(api)?(plugin)?\/).*'], 'IGNORE_REQUEST_PATTERNS': ['^(?!\/(api)?(plugin)?\/).*'],
'IGNORE_SQL_PATTERNS': [], 'IGNORE_SQL_PATTERNS': [],
'DISPLAY_DUPLICATES': 3, 'DISPLAY_DUPLICATES': 1,
'RESPONSE_HEADER': 'X-Django-Query-Count', 'RESPONSE_HEADER': 'X-Django-Query-Count',
} }

View File

@ -103,15 +103,35 @@ class BuildFilter(rest_filters.FilterSet):
return queryset.filter(project_code=None) 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. """API endpoint for accessing a list of Build objects.
- GET: Return list of objects (with filters) - GET: Return list of objects (with filters)
- POST: Create a new Build object - POST: Create a new Build object
""" """
queryset = Build.objects.all()
serializer_class = build.serializers.BuildSerializer
filterset_class = BuildFilter filterset_class = BuildFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS filter_backends = SEARCH_ORDER_FILTER_ALIAS
@ -223,12 +243,9 @@ class BuildList(APIDownloadMixin, ListCreateAPI):
return self.serializer_class(*args, **kwargs) return self.serializer_class(*args, **kwargs)
class BuildDetail(RetrieveUpdateDestroyAPI): class BuildDetail(BuildMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a Build object.""" """API endpoint for detail view of a Build object."""
queryset = Build.objects.all()
serializer_class = build.serializers.BuildSerializer
def destroy(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs):
"""Only allow deletion of a BuildOrder if the build status is CANCELLED""" """Only allow deletion of a BuildOrder if the build status is CANCELLED"""
build = self.get_object() build = self.get_object()

View File

@ -552,11 +552,12 @@ class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNo
self.save() self.save()
# Offload task to complete build allocations # Offload task to complete build allocations
InvenTree.tasks.offload_task( if not InvenTree.tasks.offload_task(
build.tasks.complete_build_allocations, build.tasks.complete_build_allocations,
self.pk, self.pk,
user.pk if user else None user.pk if user else None
) ):
raise ValidationError(_("Failed to offload task to complete build allocations"))
# Register an event # Register an event
trigger_event('build.completed', id=self.pk) trigger_event('build.completed', id=self.pk)
@ -608,24 +609,29 @@ class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNo
- Set build status to CANCELLED - Set build status to CANCELLED
- Save the Build object - Save the Build object
""" """
import build.tasks
remove_allocated_stock = kwargs.get('remove_allocated_stock', False) remove_allocated_stock = kwargs.get('remove_allocated_stock', False)
remove_incomplete_outputs = kwargs.get('remove_incomplete_outputs', 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: if remove_allocated_stock:
for item in items: # Offload task to remove allocated stock
item.complete_allocation(user) 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) # Remove incomplete outputs (if required)
if remove_incomplete_outputs: if remove_incomplete_outputs:
outputs = self.build_outputs.filter(is_building=True) outputs = self.build_outputs.filter(is_building=True)
for output in outputs: outputs.delete()
output.delete()
# Date of 'completion' is the date the build was cancelled # Date of 'completion' is the date the build was cancelled
self.completion_date = InvenTree.helpers.current_date() 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 import serializers
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
from sql_util.utils import SubquerySum
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
from InvenTree.serializers import UserSerializer from InvenTree.serializers import UserSerializer
import InvenTree.helpers import InvenTree.helpers
from InvenTree.serializers import InvenTreeDecimalField 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.models import generate_batch_code, StockItem, StockLocation
from stock.serializers import StockItemSerializerBrief, LocationSerializer from stock.serializers import StockItemSerializerBrief, LocationSerializer
@ -589,8 +587,8 @@ class BuildCancelSerializer(serializers.Serializer):
} }
remove_allocated_stock = serializers.BooleanField( remove_allocated_stock = serializers.BooleanField(
label=_('Remove Allocated Stock'), label=_('Consume Allocated Stock'),
help_text=_('Subtract any stock which has already been allocated to this build'), help_text=_('Consume any stock which has already been allocated to this build'),
required=False, required=False,
default=False, default=False,
) )
@ -611,7 +609,7 @@ class BuildCancelSerializer(serializers.Serializer):
build.cancel_build( build.cancel_build(
request.user, 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), remove_incomplete_outputs=data.get('remove_incomplete_outputs', False),
) )
@ -994,17 +992,24 @@ class BuildAutoAllocationSerializer(serializers.Serializer):
def save(self): def save(self):
"""Perform the auto-allocation step""" """Perform the auto-allocation step"""
import build.tasks
import InvenTree.tasks
data = self.validated_data 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), location=data.get('location', None),
exclude_location=data.get('exclude_location', None), exclude_location=data.get('exclude_location', None),
interchangeable=data['interchangeable'], interchangeable=data['interchangeable'],
substitutes=data['substitutes'], substitutes=data['substitutes'],
optional_items=data['optional_items'], optional_items=data['optional_items']
) ):
raise ValidationError(_("Failed to start auto-allocation task"))
class BuildItemSerializer(InvenTreeModelSerializer): class BuildItemSerializer(InvenTreeModelSerializer):

View File

@ -26,6 +26,18 @@ import part.models as part_models
logger = logging.getLogger('inventree') 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): def complete_build_allocations(build_id: int, user_id: int):
"""Complete build allocations for a specified BuildOrder.""" """Complete build allocations for a specified BuildOrder."""

View File

@ -264,8 +264,35 @@ class BuildTest(BuildAPITest):
self.assertTrue(self.build.is_complete) self.assertTrue(self.build.is_complete)
def test_cancel(self): def test_cancel(self):
"""Test that we can cancel a BuildOrder via the API.""" """Test that we can cancel a BuildOrder via the API.
bo = Build.objects.get(pk=1)
- 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}) url = reverse('api-build-cancel', kwargs={'pk': bo.pk})
@ -277,6 +304,23 @@ class BuildTest(BuildAPITest):
self.assertEqual(bo.status, BuildStatus.CANCELLED) 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): def test_delete(self):
"""Test that we can delete a BuildOrder via the API""" """Test that we can delete a BuildOrder via the API"""
bo = Build.objects.get(pk=1) 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 ref = row.build_detail?.reference ?? row.build;
let html = renderLink(ref, `/build/${row.build}/`); let html = renderLink(ref, `/build/${row.build}/`);
if (row.build_detail) {
html += `- <small>${row.build_detail.title}</small>`; html += `- <small>${row.build_detail.title}</small>`;
html += buildStatusDisplay(row.build_detail.status, { html += buildStatusDisplay(row.build_detail.status, {
classes: 'float-right', classes: 'float-right',
}); });
}
return html; return html;
} }

View File

@ -52,8 +52,10 @@ export enum ApiEndpoints {
// Build API endpoints // Build API endpoints
build_order_list = 'build/', build_order_list = 'build/',
build_order_cancel = 'build/:id/cancel/',
build_order_attachment_list = 'build/attachment/', build_order_attachment_list = 'build/attachment/',
build_line_list = 'build/line/', build_line_list = 'build/line/',
bom_list = 'bom/', bom_list = 'bom/',
// Part API endpoints // 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({ const duplicateBuild = useCreateApiFormModal({
url: ApiEndpoints.build_order_list, url: ApiEndpoints.build_order_list,
title: t`Add Build Order`, title: t`Add Build Order`,
@ -352,7 +364,9 @@ export default function BuildDetail() {
hidden: !user.hasChangeRole(UserRoles.build) hidden: !user.hasChangeRole(UserRoles.build)
}), }),
CancelItemAction({ CancelItemAction({
tooltip: t`Cancel order` tooltip: t`Cancel order`,
onClick: () => cancelBuild.open()
// TODO: Hide if build cannot be cancelled
}), }),
DuplicateItemAction({ DuplicateItemAction({
onClick: () => duplicateBuild.open(), onClick: () => duplicateBuild.open(),
@ -379,6 +393,7 @@ export default function BuildDetail() {
<> <>
{editBuild.modal} {editBuild.modal}
{duplicateBuild.modal} {duplicateBuild.modal}
{cancelBuild.modal}
<Stack spacing="xs"> <Stack spacing="xs">
<LoadingOverlay visible={instanceQuery.isFetching} /> <LoadingOverlay visible={instanceQuery.isFetching} />
<PageDetail <PageDetail