mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 11:36:44 +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 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
|
||||||
|
|
||||||
|
@ -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',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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()
|
||||||
|
@ -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()
|
||||||
|
@ -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):
|
||||||
|
@ -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."""
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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}/`);
|
||||||
|
|
||||||
html += `- <small>${row.build_detail.title}</small>`;
|
if (row.build_detail) {
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user