Skip to content

Commit

Permalink
Build order cancel (#7153)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
SchrodingersGat authored May 4, 2024
1 parent 7f12d55 commit 5b0889d
Show file tree
Hide file tree
Showing 10 changed files with 142 additions and 36 deletions.
5 changes: 4 additions & 1 deletion src/backend/InvenTree/InvenTree/api_version.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/backend/InvenTree/InvenTree/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@
},
'IGNORE_REQUEST_PATTERNS': ['^(?!\/(api)?(plugin)?\/).*'],
'IGNORE_SQL_PATTERNS': [],
'DISPLAY_DUPLICATES': 3,
'DISPLAY_DUPLICATES': 1,
'RESPONSE_HEADER': 'X-Django-Query-Count',
}

Expand Down
31 changes: 24 additions & 7 deletions src/backend/InvenTree/build/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
26 changes: 16 additions & 10 deletions src/backend/InvenTree/build/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
25 changes: 15 additions & 10 deletions src/backend/InvenTree/build/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Expand All @@ -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),
)

Expand Down Expand Up @@ -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):
Expand Down
12 changes: 12 additions & 0 deletions src/backend/InvenTree/build/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
48 changes: 46 additions & 2 deletions src/backend/InvenTree/build/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})

Expand All @@ -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)
Expand Down
10 changes: 6 additions & 4 deletions src/backend/InvenTree/templates/js/translated/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions src/frontend/src/enums/ApiEndpoints.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion src/frontend/src/pages/build/BuildDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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(),
Expand All @@ -379,6 +393,7 @@ export default function BuildDetail() {
<>
{editBuild.modal}
{duplicateBuild.modal}
{cancelBuild.modal}
<Stack spacing="xs">
<LoadingOverlay visible={instanceQuery.isFetching} />
<PageDetail
Expand Down

0 comments on commit 5b0889d

Please sign in to comment.