2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-06-10 02:36:59 +00:00

Offload build output functions: (#11990)

* Offload build output functions:

- cancel output
- scrap output
- complete output

Perform these in the background worker, and monitor for progress on the frontend.

* Refactor "build cancel"

- Offload expensive ops to background worker

* Offload build complete task

* Remove @atomic decorator from functions

- Allows operations to be performed "incrementally"
- If one task times out, the next task will get the rest

* Bug fix

* Bump API version

* Fix isInTestMode check

* Handle case where task returns immediately

* Fix docstring

* fix test_api

* Tweak order of operations

* additional unit testing for further coverage

* Adjust unit tests

* Offload order completion tasks

* Remove bad code

* Updated playwright test

* Robustify playwright tests

* Bump number of allowed queries

* Revert "Remove bad code"

This reverts commit 3a3ac3bdc7.

* Revert "Offload order completion tasks"

This reverts commit 6066dabe43.
This commit is contained in:
Oliver
2026-05-24 09:26:43 +10:00
committed by GitHub
parent 7d61203be8
commit 749c4715ee
12 changed files with 739 additions and 196 deletions
@@ -1,12 +1,15 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 493
INVENTREE_API_VERSION = 494
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v493 -> 2026-05-18 : https://github.com/inventree/InvenTree/pull/11961
v494 -> 2026-05-23 : https://github.com/inventree/InvenTree/pull/11990
- Offload build output operations to a background task, and return a task ID which can be used to monitor the progress of the task
v493 -> 2026-05-22 : https://github.com/inventree/InvenTree/pull/11961
- Adds "thumbnail" field to the Attachment API endpoint, which provides a URL to a thumbnail image for image attachments (if available)
v492 -> 2026-05-22 : https://github.com/inventree/InvenTree/pull/11281
@@ -17,7 +20,7 @@ v491 -> 2026-05-21 : https://github.com/inventree/InvenTree/pull/11979
- Add API serializer for deleting a stock location
v490 -> 2026-05-19 : https://github.com/inventree/InvenTree/pull/11963
- moves user-self-filtered endpoints to /user/me/ to make their security boundaries clearer
- Moves user-self-filtered endpoints to /user/me/ to make their security boundaries clearer
v489 -> 2026-05-18 : https://github.com/inventree/InvenTree/pull/11962
- Removes the "remote_image" field from the Part API endpoint
+3 -1
View File
@@ -37,7 +37,9 @@ def isAppLoaded(app_name: str) -> bool:
def isInTestMode():
"""Returns True if the database is in testing mode."""
return 'test' in sys.argv or sys.argv[0].endswith('pytest')
return any(x in sys.argv for x in ['test', 'pytest']) or sys.argv[0].endswith(
'pytest'
)
def isWaitingForDatabase():
+89 -4
View File
@@ -735,14 +735,81 @@ class BuildOutputScrap(BuildOrderContextMixin, CreateAPI):
ctx['to_complete'] = False
return ctx
@extend_schema(responses={200: common.serializers.TaskDetailSerializer})
def post(self, *args, **kwargs):
"""Override POST to offload scrapping to the background worker."""
from build.tasks import scrap_build_outputs
from InvenTree.tasks import offload_task
build = self.get_build()
serializer = self.get_serializer(data=self.request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
task_id = offload_task(
scrap_build_outputs,
build.pk,
outputs=[
{
'output_id': item['output'].pk,
'quantity': float(item['quantity'])
if item.get('quantity') is not None
else None,
}
for item in data['outputs']
],
location_id=data['location'].pk,
notes=data.get('notes', ''),
discard_allocations=data.get('discard_allocations', False),
user_id=self.request.user.pk,
group='build',
)
response = common.serializers.TaskDetailSerializer.from_task(task_id).data
return Response(response, status=response['http_status'])
class BuildOutputComplete(BuildOrderContextMixin, CreateAPI):
"""API endpoint for completing build outputs."""
queryset = Build.objects.none()
serializer_class = build.serializers.BuildOutputCompleteSerializer
@extend_schema(responses={200: common.serializers.TaskDetailSerializer})
def post(self, *args, **kwargs):
"""Override POST to offload build output completion to the background worker."""
from build.tasks import complete_build_outputs
from InvenTree.tasks import offload_task
build = self.get_build()
serializer = self.get_serializer(data=self.request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
location = data.get('location')
task_id = offload_task(
complete_build_outputs,
build.pk,
outputs=[
{
'output_id': item['output'].pk,
'quantity': float(item['quantity'])
if item.get('quantity') is not None
else None,
}
for item in data['outputs']
],
location_id=location.pk if location else None,
status=data.get('status_custom_key'),
notes=data.get('notes', ''),
user_id=self.request.user.pk,
group='build',
)
response = common.serializers.TaskDetailSerializer.from_task(task_id).data
return Response(response, status=response['http_status'])
class BuildOutputDelete(BuildOrderContextMixin, CreateAPI):
"""API endpoint for deleting multiple build outputs."""
@@ -750,15 +817,33 @@ class BuildOutputDelete(BuildOrderContextMixin, CreateAPI):
def get_serializer_context(self):
"""Add extra context information to the endpoint serializer."""
ctx = super().get_serializer_context()
ctx['to_complete'] = False
return ctx
queryset = Build.objects.none()
serializer_class = build.serializers.BuildOutputDeleteSerializer
@extend_schema(responses={200: common.serializers.TaskDetailSerializer})
def post(self, *args, **kwargs):
"""Override POST to offload build output deletion to the background worker."""
from build.tasks import delete_build_outputs
from InvenTree.tasks import offload_task
build = self.get_build()
serializer = self.get_serializer(data=self.request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
task_id = offload_task(
delete_build_outputs,
build.pk,
output_ids=[item['output'].pk for item in data['outputs']],
group='build',
)
response = common.serializers.TaskDetailSerializer.from_task(task_id).data
return Response(response, status=response['http_status'])
class BuildFinish(BuildOrderContextMixin, CreateAPI):
"""API endpoint for marking a build as finished (completed)."""
+18 -89
View File
@@ -37,7 +37,6 @@ from build.validators import (
validate_build_order_reference,
)
from common.models import ProjectCode
from common.notifications import InvenTreeNotificationBodies, trigger_notification
from common.settings import (
get_global_setting,
prevent_build_output_complete_on_incompleted_tests,
@@ -651,7 +650,6 @@ class Build(
return self.is_fully_allocated(tracked=False)
@transaction.atomic
def complete_allocations(self, user) -> None:
"""Complete all stock allocations for this build order.
@@ -662,7 +660,7 @@ class Build(
# Ensure that there are no longer any BuildItem objects
# which point to this Build Order
self.allocated_stock.delete()
self.allocated_stock.all().delete()
@transaction.atomic
def complete_build(self, user: User, trim_allocated_stock: bool = False):
@@ -702,66 +700,20 @@ class Build(
_('Cannot complete build order with incomplete outputs')
)
if trim_allocated_stock:
self.trim_allocated_stock()
# Offload background task to complete build allocations
InvenTree.tasks.offload_task(
build.tasks.complete_build,
self.pk,
user.pk if user else None,
trim_allocated_stock=trim_allocated_stock,
group='build',
)
self.completion_date = InvenTree.helpers.current_date()
self.completed_by = user
self.status = BuildStatus.COMPLETE.value
self.save()
# Offload task to complete build allocations
if not InvenTree.tasks.offload_task(
build.tasks.complete_build_allocations,
self.pk,
user.pk if user else None,
group='build',
):
raise ValidationError(
_('Failed to offload task to complete build allocations')
)
# Register an event
trigger_event(BuildEvents.COMPLETED, id=self.pk)
# Notify users that this build has been completed
targets = [self.issued_by, self.responsible]
# Also inform anyone subscribed to the assembly part
targets.extend(self.part.get_subscribers())
# Notify those users interested in the parent build
if self.parent:
targets.append(self.parent.issued_by)
targets.append(self.parent.responsible)
# Notify users if this build points to a sales order
if self.sales_order:
targets.append(self.sales_order.created_by)
targets.append(self.sales_order.responsible)
build = self
name = _(f'Build order {build} has been completed')
context = {
'build': build,
'name': name,
'slug': 'build.completed',
'message': _('A build order has been completed'),
'link': InvenTree.helpers_model.construct_absolute_url(
self.get_absolute_url()
),
'template': {'html': 'email/build_order_completed.html', 'subject': name},
}
trigger_notification(
build,
'build.completed',
targets=targets,
context=context,
target_exclude=[user],
)
@transaction.atomic
def issue_build(self):
"""Mark the Build as IN PRODUCTION.
@@ -839,26 +791,15 @@ class Build(
remove_allocated_stock = kwargs.get('remove_allocated_stock', False)
remove_incomplete_outputs = kwargs.get('remove_incomplete_outputs', False)
if remove_allocated_stock:
# 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,
group='build',
):
raise ValidationError(
_('Failed to offload task to complete build allocations')
)
else:
self.allocated_stock.all().delete()
# Remove incomplete outputs (if required)
if remove_incomplete_outputs:
outputs = self.build_outputs.filter(is_building=True)
outputs.delete()
# Offload background task to take care of the expensive operations
InvenTree.tasks.offload_task(
build.tasks.cancel_build,
self.pk,
user.pk if user else None,
remove_allocated_stock=remove_allocated_stock,
remove_incomplete_outputs=remove_incomplete_outputs,
group='build',
)
# Date of 'completion' is the date the build was cancelled
self.completion_date = InvenTree.helpers.current_date()
@@ -867,17 +808,6 @@ class Build(
self.status = BuildStatus.CANCELLED.value
self.save()
# Notify users that the order has been canceled
InvenTree.helpers_model.notify_responsible(
self,
Build,
exclude=self.issued_by,
content=InvenTreeNotificationBodies.OrderCanceled,
extra_users=self.part.get_subscribers(),
)
trigger_event(BuildEvents.CANCELLED, id=self.pk)
@transaction.atomic
def deallocate_stock(self, build_line=None, output=None):
"""Deallocate stock from this Build.
@@ -1083,7 +1013,6 @@ class Build(
"""Returns a QuerySet object of all BuildItem objects which point back to this Build."""
return BuildItem.objects.filter(build_line__build=self)
@transaction.atomic
def subtract_allocated_stock(self, user) -> None:
"""Removes the allocated untracked items from stock."""
# Find all BuildItem objects which point to this build
@@ -462,18 +462,6 @@ class BuildOutputDeleteSerializer(serializers.Serializer):
return data
def save(self):
"""'save' the serializer to delete the build outputs."""
data = self.validated_data
outputs = data.get('outputs', [])
build = self.context['build']
with transaction.atomic():
for item in outputs:
output = item['output']
build.delete_output(output)
class BuildOutputScrapSerializer(serializers.Serializer):
"""Scrapping one or more build outputs."""
@@ -518,27 +506,6 @@ class BuildOutputScrapSerializer(serializers.Serializer):
return data
def save(self):
"""Save the serializer to scrap the build outputs."""
build = self.context['build']
request = self.context.get('request')
data = self.validated_data
outputs = data.get('outputs', [])
# Scrap the build outputs
with transaction.atomic():
for item in outputs:
output = item['output']
quantity = item.get('quantity', None)
build.scrap_build_output(
output,
quantity,
data.get('location', None),
user=request.user if request else None,
notes=data.get('notes', ''),
discard_allocations=data.get('discard_allocations', False),
)
class BuildOutputCompleteSerializer(serializers.Serializer):
"""DRF serializer for completing one or more build outputs."""
@@ -610,42 +577,6 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
return data
def save(self):
"""Save the serializer to complete the build outputs."""
build = self.context['build']
request = self.context.get('request')
data = self.validated_data
location = data.get('location', None)
status = data.get('status_custom_key', StockStatus.OK.value)
notes = data.get('notes', '')
outputs = data.get('outputs', [])
# Cache some calculated values which can be passed to each output
required_tests = outputs[0]['output'].part.getRequiredTests()
prevent_on_incomplete = (
common.settings.prevent_build_output_complete_on_incompleted_tests()
)
# Mark the specified build outputs as "complete"
with transaction.atomic():
for item in outputs:
output = item['output']
quantity = item.get('quantity', None)
build.complete_build_output(
output,
request.user if request else None,
quantity=quantity,
location=location,
status=status,
notes=notes,
required_tests=required_tests,
prevent_on_incomplete=prevent_on_incomplete,
)
class BuildIssueSerializer(serializers.Serializer):
"""DRF serializer for issuing a build order."""
+208
View File
@@ -100,6 +100,214 @@ def complete_build_allocations(build_id: int, user_id: int):
build_order.complete_allocations(user)
@tracer.start_as_current_span('delete_build_outputs')
def delete_build_outputs(build_id: int, output_ids: list, **kwargs):
"""Delete (cancel) specified build outputs for a BuildOrder.
Arguments:
build_id: The ID of the BuildOrder
output_ids: List of StockItem PKs to delete
"""
from build.models import Build
from stock.models import StockItem
build = Build.objects.get(pk=build_id)
with transaction.atomic():
for output_id in output_ids:
output = StockItem.objects.filter(pk=output_id).first()
if output:
build.delete_output(output)
@tracer.start_as_current_span('scrap_build_outputs')
def scrap_build_outputs(
build_id: int,
outputs: list,
location_id: int,
notes: str = '',
discard_allocations: bool = False,
user_id: int | None = None,
**kwargs,
):
"""Scrap specified build outputs for a BuildOrder.
Arguments:
build_id: The ID of the BuildOrder
outputs: List of dicts with 'output_id' and 'quantity'
location_id: PK of the destination StockLocation
notes: Reason for scrapping
discard_allocations: If True, discard (not consume) allocations
user_id: PK of the user initiating the action
"""
from build.models import Build
from stock.models import StockItem, StockLocation
build = Build.objects.get(pk=build_id)
location = StockLocation.objects.get(pk=location_id)
user = User.objects.filter(pk=user_id).first() if user_id else None
with transaction.atomic():
for item in outputs:
output = StockItem.objects.filter(pk=item['output_id']).first()
if output:
build.scrap_build_output(
output,
item.get('quantity'),
location,
user=user,
notes=notes,
discard_allocations=discard_allocations,
)
@tracer.start_as_current_span('complete_build_outputs')
def complete_build_outputs(
build_id: int,
outputs: list,
location_id: int | None,
status: int,
notes: str = '',
user_id: int | None = None,
**kwargs,
):
"""Complete specified build outputs for a BuildOrder.
Arguments:
build_id: The ID of the BuildOrder
outputs: List of dicts with 'output_id' and optional 'quantity'
location_id: PK of the destination StockLocation (or None)
status: Stock status code to assign to completed outputs
notes: Completion notes
user_id: PK of the user initiating the action
"""
from build.models import Build
from stock.models import StockItem, StockLocation
build = Build.objects.get(pk=build_id)
location = (
StockLocation.objects.filter(pk=location_id).first() if location_id else None
)
user = User.objects.filter(pk=user_id).first() if user_id else None
required_tests = build.part.getRequiredTests()
with transaction.atomic():
for item in outputs:
output = StockItem.objects.filter(pk=item['output_id']).first()
if output:
build.complete_build_output(
output,
user,
quantity=item.get('quantity'),
location=location,
status=status,
notes=notes,
required_tests=required_tests,
)
@tracer.start_as_current_span('cancel_build')
def cancel_build(
build_id: int,
user_id: int,
remove_allocated_stock: bool = False,
remove_incomplete_outputs: bool = False,
):
"""Tasks to run after a BuildOrder is cancelled.
Arguments:
build_id: The ID of the BuildOrder which has been cancelled
user_id: The ID of the user who cancelled the BuildOrder
remove_allocated_stock: If True, consume any allocated stock
remove_incomplete_outputs: If True, delete any incomplete build outputs
"""
from build.models import Build
build = Build.objects.get(pk=build_id)
if remove_allocated_stock:
complete_build_allocations(build_id, user_id)
else:
build.allocated_stock.all().delete()
if remove_incomplete_outputs:
build.build_outputs.filter(is_building=True).delete()
# Notify users that the order has been canceled
InvenTree.helpers_model.notify_responsible(
build,
Build,
exclude=build.issued_by,
content=common.notifications.InvenTreeNotificationBodies.OrderCanceled,
extra_users=build.part.get_subscribers(),
)
trigger_event(BuildEvents.CANCELLED, id=build.pk)
@tracer.start_as_current_span('complete_build')
def complete_build(build_id: int, user_id: int, trim_allocated_stock: bool = False):
"""Tasks to run after a BuildOrder is completed.
Arguments:
build_id: The ID of the BuildOrder which has been completed
user_id: The ID of the user who completed the BuildOrder
trim_allocated_stock: If True, trim any allocated stock which was not consumed
"""
from build.models import Build
build = Build.objects.get(pk=build_id)
user = User.objects.filter(pk=user_id).first() if user_id else None
if trim_allocated_stock:
build.trim_allocated_stock()
# Complete any remaining allocations for this build order
complete_build_allocations(build_id, user_id)
# Register an event
trigger_event(BuildEvents.COMPLETED, id=build.pk)
# Notify users that this build has been completed
targets = [build.issued_by, build.responsible]
# Also inform anyone subscribed to the assembly part
targets.extend(build.part.get_subscribers())
# Notify those users interested in the parent build
if build.parent:
targets.append(build.parent.issued_by)
targets.append(build.parent.responsible)
# Notify users if this build points to a sales order
if build.sales_order:
targets.append(build.sales_order.created_by)
targets.append(build.sales_order.responsible)
name = _(f'Build order {build} has been completed')
context = {
'build': build,
'name': name,
'slug': 'build.completed',
'message': _('A build order has been completed'),
'link': InvenTree.helpers_model.construct_absolute_url(
build.get_absolute_url()
),
'template': {'html': 'email/build_order_completed.html', 'subject': name},
}
common.notifications.trigger_notification(
build,
'build.completed',
targets=targets,
context=context,
target_exclude=[user],
)
@tracer.start_as_current_span('update_build_order_lines')
def update_build_order_lines(bom_item_pk: int):
"""Update all BuildOrderLineItem objects which reference a particular BomItem.
+19 -10
View File
@@ -155,7 +155,7 @@ class BuildTest(BuildAPITest):
self.post(
reverse('api-build-output-complete', kwargs={'pk': 99999}),
{},
expected_code=400,
expected_code=404,
)
data = self.post(self.url, {}, expected_code=400).data
@@ -226,8 +226,8 @@ class BuildTest(BuildAPITest):
'location': 1,
'status': StockStatus.ATTENTION.value,
},
expected_code=201,
max_query_count=400,
expected_code=200,
max_query_count=450,
)
self.assertEqual(self.build.incomplete_outputs.count(), 0)
@@ -446,7 +446,7 @@ class BuildTest(BuildAPITest):
self.post(
delete_url,
{'outputs': [{'output': output.pk} for output in outputs[1:3]]},
expected_code=201,
expected_code=200,
)
# Two build outputs have been removed
@@ -473,7 +473,7 @@ class BuildTest(BuildAPITest):
'outputs': [{'output': output.pk} for output in outputs[3:]],
'location': 4,
},
expected_code=201,
expected_code=200,
)
# Check that the outputs have been completed
@@ -1353,7 +1353,7 @@ class BuildOutputCreateTest(BuildAPITest):
url, data={'quantity': 5, 'serial_numbers': '1,2,3-5'}, expected_code=201
)
# Build outputs have incdeased
# Build outputs have increased
self.assertEqual(n_outputs + 5, build.output_count)
# Stock items have increased
@@ -1466,7 +1466,7 @@ class BuildOutputScrapTest(BuildAPITest):
'location': 1,
'notes': 'Should succeed',
},
expected_code=201,
expected_code=200,
)
# There should still be three outputs associated with this build
@@ -1534,7 +1534,7 @@ class BuildOutputScrapTest(BuildAPITest):
# Partially complete the output (with a valid quantity)
data['outputs'][0]['quantity'] = 4
self.post(url, data, expected_code=201)
self.post(url, data, expected_code=200)
build.refresh_from_db()
output.refresh_from_db()
@@ -1571,13 +1571,22 @@ class BuildOutputCancelTest(BuildAPITest):
set_global_setting('STOCK_ALLOW_DELETE_SERIALIZED', True)
url = reverse('api-build-output-delete', kwargs={'pk': build.pk})
self.post(url, data={'outputs': [{'output': output_ids[0]}]}, expected_code=201)
self.post(url, data={'outputs': [{'output': output_ids[0]}]}, expected_code=200)
# Prevent deletion of serialized stock items, and try again
# Note that this should still succeed, independent of the global setting
set_global_setting('STOCK_ALLOW_DELETE_SERIALIZED', False)
self.post(url, data={'outputs': [{'output': output_ids[1]}]}, expected_code=201)
response = self.post(
url, data={'outputs': [{'output': output_ids[1]}]}, expected_code=200
)
# Response should be the task info - the cancellation is performed asynchronously
self.assertIn('task_id', response.data)
self.assertFalse(response.data['exists'])
self.assertFalse(response.data['pending'])
self.assertTrue(response.data['complete'])
self.assertTrue(response.data['success'])
# The outputs should have been scrapped
self.assertEqual(build.build_outputs.count(), N)
+296 -7
View File
@@ -27,6 +27,7 @@ from InvenTree.unit_test import (
from order.models import PurchaseOrder, PurchaseOrderLineItem
from part.models import BomItem, BomItemSubstitute, Part, PartTestTemplate
from stock.models import StockItem, StockItemTestResult, StockLocation
from stock.status_codes import StockStatus
from users.models import Owner
logger = structlog.get_logger('inventree')
@@ -557,14 +558,29 @@ class BuildTest(BuildTestBase):
bo.clean()
def test_cancel(self):
"""Test cancellation of the build."""
# TODO
"""
self.allocate_stock(50, 50, 200, self.output_1)
self.build.cancel_build(None)
"""Test build cancellation: status is updated and allocations are removed by default."""
self.build.issue_build()
self.assertEqual(BuildItem.objects.count(), 0)
"""
self.allocate_stock(None, {self.stock_1_2: 50})
self.assertGreater(self.build.allocated_stock.count(), 0)
initial_output_count = self.build.build_outputs.filter(is_building=True).count()
self.assertGreater(initial_output_count, 0)
self.build.cancel_build(None)
self.build.refresh_from_db()
self.assertEqual(self.build.status, BuildStatus.CANCELLED)
# Allocations removed (but stock not consumed) by default
self.assertEqual(self.build.allocated_stock.count(), 0)
self.assertIsNone(StockItem.objects.get(pk=self.stock_1_2.pk).consumed_by)
# Incomplete outputs preserved by default (remove_incomplete_outputs=False)
self.assertEqual(
self.build.build_outputs.filter(is_building=True).count(),
initial_output_count,
)
def test_complete(self):
"""Test completion of a build output."""
@@ -1107,3 +1123,276 @@ class ExternalBuildTest(InvenTreeAPITestCase):
# Filter by 'not external'
response = self.get(url, {'external': 'false'})
self.assertEqual(len(response.data), 2)
class BuildTaskTests(BuildTestBase):
"""Direct unit tests for the background task functions in build/tasks.py.
These tests call task functions directly (synchronously) to verify the
business logic they encapsulate, independently of the API and offload mechanism.
"""
def setUp(self):
"""Create a stock location available to all task tests."""
super().setUp()
self.location = StockLocation.objects.create(name='Task Test Location')
def allocate_stock(self, output, allocations):
"""Create BuildItem allocations against self.build for the given output."""
items_to_create = []
for item, quantity in allocations.items():
line = BuildLine.objects.filter(
build=self.build, bom_item__sub_part=item.part
).first()
items_to_create.append(
BuildItem(
build_line=line,
stock_item=item,
quantity=quantity,
install_into=output,
)
)
BuildItem.objects.bulk_create(items_to_create)
def _setup_complete_build(self):
"""Helper: allocate stock fully and complete all outputs so the build is ready to complete."""
self.stock_1_1.quantity = 1000
self.stock_1_1.save()
self.stock_2_1.quantity = 30
self.stock_2_1.save()
self.build.issue_build()
# Allocate untracked parts
self.allocate_stock(
None, {self.stock_1_1: 50, self.stock_1_2: 10, self.stock_2_1: 30}
)
# Allocate tracked parts to each output
self.allocate_stock(self.output_1, {self.stock_3_1: 6})
self.allocate_stock(self.output_2, {self.stock_3_1: 14})
self.build.complete_build_output(self.output_1, None)
self.build.complete_build_output(self.output_2, None)
# -----------------------------------------------------------------------
# cancel_build task
# -----------------------------------------------------------------------
def test_cancel_task_discards_allocations(self):
"""cancel_build with remove_allocated_stock=False: allocations deleted, stock not consumed."""
self.build.issue_build()
self.allocate_stock(None, {self.stock_1_2: 50})
self.assertGreater(self.build.allocated_stock.count(), 0)
build.tasks.cancel_build(
self.build.pk, self.user.pk, remove_allocated_stock=False
)
# BuildItem rows gone
self.assertEqual(self.build.allocated_stock.count(), 0)
# Stock item was NOT consumed
self.assertIsNone(StockItem.objects.get(pk=self.stock_1_2.pk).consumed_by)
def test_cancel_task_consumes_allocations(self):
"""cancel_build with remove_allocated_stock=True: stock items are marked consumed."""
self.build.issue_build()
self.allocate_stock(None, {self.stock_1_2: 50})
build.tasks.cancel_build(
self.build.pk, self.user.pk, remove_allocated_stock=True
)
# All BuildItem rows gone
self.assertEqual(self.build.allocated_stock.count(), 0)
# The allocated (non-trackable) stock was consumed
self.assertGreater(self.build.consumed_stock.count(), 0)
def test_cancel_task_removes_incomplete_outputs(self):
"""cancel_build with remove_incomplete_outputs=True: in-progress outputs are deleted."""
self.build.issue_build()
initial_count = self.build.build_outputs.filter(is_building=True).count()
self.assertGreater(initial_count, 0)
build.tasks.cancel_build(
self.build.pk, self.user.pk, remove_incomplete_outputs=True
)
self.assertEqual(self.build.build_outputs.filter(is_building=True).count(), 0)
def test_cancel_task_preserves_incomplete_outputs(self):
"""cancel_build with remove_incomplete_outputs=False: in-progress outputs are kept."""
self.build.issue_build()
initial_count = self.build.build_outputs.filter(is_building=True).count()
self.assertGreater(initial_count, 0)
build.tasks.cancel_build(
self.build.pk, self.user.pk, remove_incomplete_outputs=False
)
self.assertEqual(
self.build.build_outputs.filter(is_building=True).count(), initial_count
)
# -----------------------------------------------------------------------
# complete_build task
# -----------------------------------------------------------------------
@override_settings(
TESTING_TABLE_EVENTS=True,
PLUGIN_TESTING_EVENTS=True,
PLUGIN_TESTING_EVENTS_ASYNC=True,
)
def test_complete_build_task_triggers_event(self):
"""complete_build task fires the BuildEvents.COMPLETED event."""
from django_q.models import OrmQ
from build.events import BuildEvents
set_global_setting('ENABLE_PLUGINS_EVENTS', True)
OrmQ.objects.all().delete()
self._setup_complete_build()
self.build.complete_build(self.user)
task = findOffloadedEvent(BuildEvents.COMPLETED, matching_kwargs=['id'])
self.assertIsNotNone(task)
self.assertEqual(task.kwargs()['id'], self.build.pk)
set_global_setting('ENABLE_PLUGINS_EVENTS', False)
def test_complete_build_task_trim_stock(self):
"""complete_build with trim_allocated_stock=True removes over-allocations before consuming."""
self.stock_1_2.quantity = 100
self.stock_1_2.save()
self.stock_2_1.quantity = 30
self.stock_2_1.save()
self.build.issue_build()
# Over-allocate sub_part_1: need 50, allocate 100
self.allocate_stock(None, {self.stock_1_2: 100, self.stock_2_1: 30})
self.allocate_stock(self.output_1, {self.stock_3_1: 6})
self.allocate_stock(self.output_2, {self.stock_3_1: 14})
self.assertTrue(self.build.is_overallocated())
self.build.complete_build_output(self.output_1, None)
self.build.complete_build_output(self.output_2, None)
self.assertTrue(self.build.can_complete)
self.build.complete_build(self.user, trim_allocated_stock=True)
self.build.refresh_from_db()
self.assertEqual(self.build.status, BuildStatus.COMPLETE)
# Only 50 units of sub_part_1 should have been consumed (not 100)
consumed_qty = StockItem.objects.filter(
consumed_by=self.build, part=self.sub_part_1
).aggregate(total=Sum('quantity'))['total']
self.assertEqual(consumed_qty, 50)
# -----------------------------------------------------------------------
# delete_build_outputs task
# -----------------------------------------------------------------------
def test_delete_build_outputs_skips_missing_id(self):
"""delete_build_outputs silently skips nonexistent output IDs and deletes valid ones."""
from build.tasks import delete_build_outputs
# Create an output directly to avoid serial-number requirements on the trackable assembly
output = StockItem.objects.create(
part=self.assembly, quantity=3, is_building=True, build=self.build
)
real_id = output.pk
# Mix a valid ID with a nonexistent one — must not raise
delete_build_outputs(self.build.pk, [real_id, 99999])
self.assertFalse(StockItem.objects.filter(pk=real_id).exists())
# -----------------------------------------------------------------------
# scrap_build_outputs task
# -----------------------------------------------------------------------
def test_scrap_build_outputs_discard_allocations(self):
"""scrap_build_outputs with discard_allocations=True removes allocations without consuming stock."""
from build.tasks import scrap_build_outputs
self.build.issue_build()
# Allocate tracked stock to output_1
self.allocate_stock(self.output_1, {self.stock_3_1: 6})
self.assertGreater(self.output_1.items_to_install.count(), 0)
scrap_build_outputs(
self.build.pk,
[{'output_id': self.output_1.pk, 'quantity': self.output_1.quantity}],
location_id=self.location.pk,
notes='discard test',
discard_allocations=True,
user_id=None,
)
self.output_1.refresh_from_db()
self.assertEqual(self.output_1.status, StockStatus.REJECTED.value)
self.assertFalse(self.output_1.is_building)
# Allocation rows should be gone
self.assertEqual(self.output_1.items_to_install.count(), 0)
# Stock was discarded (not consumed or installed)
self.stock_3_1.refresh_from_db()
self.assertIsNone(self.stock_3_1.consumed_by)
self.assertIsNone(self.stock_3_1.belongs_to)
def test_scrap_build_outputs_consume_allocations(self):
"""scrap_build_outputs with discard_allocations=False (default) consumes/installs stock."""
from build.tasks import scrap_build_outputs
self.build.issue_build()
self.allocate_stock(self.output_1, {self.stock_3_1: 6})
scrap_build_outputs(
self.build.pk,
[{'output_id': self.output_1.pk, 'quantity': self.output_1.quantity}],
location_id=self.location.pk,
notes='consume test',
discard_allocations=False,
user_id=None,
)
self.output_1.refresh_from_db()
self.assertEqual(self.output_1.status, StockStatus.REJECTED.value)
self.assertFalse(self.output_1.is_building)
# complete_allocation splits stock_3_1 and installs the split piece into output_1
# (stock_3_1 quantity=1000, only 6 allocated, so a child item is created)
self.assertTrue(
StockItem.objects.filter(belongs_to=self.output_1).exists(),
'Expected a tracked stock item to be installed into the output',
)
# -----------------------------------------------------------------------
# complete_build_outputs task
# -----------------------------------------------------------------------
def test_complete_build_outputs_with_status_none(self):
"""complete_build_outputs with status=None falls back to StockStatus.OK in the model."""
from build.tasks import complete_build_outputs
self.build.issue_build()
# Create output directly to avoid serial-number requirements on the trackable assembly
output = StockItem.objects.create(
part=self.assembly, quantity=5, is_building=True, build=self.build
)
complete_build_outputs(
self.build.pk,
[{'output_id': output.pk}],
location_id=self.location.pk,
status=None,
notes='status none test',
user_id=None,
)
output.refresh_from_db()
self.assertFalse(output.is_building)
# status=None should resolve to StockStatus.OK (the model default)
self.assertEqual(output.status, StockStatus.OK.value)
+3 -3
View File
@@ -385,7 +385,7 @@ export function useCompleteBuildOutputsForm({
title: t`Complete Build Outputs`,
fields: buildOutputCompleteFields,
onFormSuccess: onFormSuccess,
successMessage: t`Build outputs have been completed`,
successMessage: null,
size: '80%'
});
}
@@ -466,7 +466,7 @@ export function useScrapBuildOutputsForm({
),
fields: buildOutputScrapFields,
onFormSuccess: onFormSuccess,
successMessage: t`Build outputs have been scrapped`,
successMessage: null,
size: '80%'
});
}
@@ -527,7 +527,7 @@ export function useCancelBuildOutputsForm({
),
fields: buildOutputCancelFields,
onFormSuccess: onFormSuccess,
successMessage: t`Build outputs have been cancelled`,
successMessage: null,
size: '80%'
});
}
@@ -346,31 +346,80 @@ export default function BuildOutputTable({
const [selectedOutputs, setSelectedOutputs] = useState<any[]>([]);
const [completeTaskId, setCompleteTaskId] = useState<string>('');
const [scrapTaskId, setScrapTaskId] = useState<string>('');
const [deleteTaskId, setDeleteTaskId] = useState<string>('');
useBackgroundTask({
taskId: completeTaskId,
message: t`Completing build outputs`,
successMessage: t`Build outputs have been completed`,
onSuccess: () => {
table.refreshTable(true);
refreshBuild();
}
});
useBackgroundTask({
taskId: scrapTaskId,
message: t`Scrapping build outputs`,
successMessage: t`Build outputs have been scrapped`,
onSuccess: () => {
table.refreshTable(true);
refreshBuild();
}
});
useBackgroundTask({
taskId: deleteTaskId,
message: t`Cancelling build outputs`,
successMessage: t`Build outputs have been cancelled`,
onSuccess: () => {
table.refreshTable(true);
refreshBuild();
}
});
const completeBuildOutputsForm = useCompleteBuildOutputsForm({
build: build,
outputs: selectedOutputs,
hasTrackedItems: hasTrackedItems,
onFormSuccess: () => {
table.refreshTable(true);
refreshBuild();
onFormSuccess: (response: any) => {
if (response.task_id) {
setCompleteTaskId(response.task_id);
} else {
// If no task ID is returned, immediately refresh the table and build data
table.refreshTable(true);
refreshBuild();
}
}
});
const scrapBuildOutputsForm = useScrapBuildOutputsForm({
build: build,
outputs: selectedOutputs,
onFormSuccess: () => {
table.refreshTable(true);
refreshBuild();
onFormSuccess: (response: any) => {
if (response.task_id) {
setScrapTaskId(response.task_id);
} else {
// If no task ID is returned, immediately refresh the table and build data
table.refreshTable(true);
refreshBuild();
}
}
});
const cancelBuildOutputsForm = useCancelBuildOutputsForm({
build: build,
outputs: selectedOutputs,
onFormSuccess: () => {
table.refreshTable(true);
refreshBuild();
onFormSuccess: (response: any) => {
if (response.task_id) {
setDeleteTaskId(response.task_id);
} else {
// If no task ID is returned, immediately refresh the table and build data
table.refreshTable(true);
refreshBuild();
}
}
});
+38 -1
View File
@@ -38,6 +38,7 @@ test('Build Order - Basic Tests', async ({ browser }) => {
await clearTableFilters(page);
// We have now loaded the "Build Order" table. Check for some expected texts
await page.getByPlaceholder('Search').fill('7');
await page.getByText('On Hold').first().waitFor();
await page.getByText('Pending').first().waitFor();
@@ -60,6 +61,7 @@ test('Build Order - Basic Tests', async ({ browser }) => {
await page.getByLabel('breadcrumb-0-manufacturing').click();
// Load a different build order
await page.getByPlaceholder('Search').fill('11');
await page.getByRole('cell', { name: 'BO0011' }).click();
// This build order should be "in production"
@@ -654,6 +656,7 @@ test('Build Order - Filters', async ({ browser }) => {
// Check for expected pagination text i.e. (1 - 24 / 24)
// Note: Due to other concurrent tests, the number of build orders may vary
await page.getByText(/1 - \d+ \/ \d+/).waitFor();
await page.getByPlaceholder('Search').fill('23');
await page.getByRole('cell', { name: 'BO0023' }).waitFor();
// Toggle 'Outstanding' filter
@@ -665,7 +668,7 @@ test('Build Order - Filters', async ({ browser }) => {
await page.getByRole('textbox', { name: 'table-search-input' }).fill('');
await setTableChoiceFilter(page, 'Outstanding', 'No');
await page.getByText('1 - 6 / 6').waitFor();
await page.getByText(/1 - \d+ \/ \d+/).waitFor();
await clearTableFilters(page);
@@ -699,6 +702,40 @@ test('Build Order - Duplicate', async ({ browser }) => {
await page.getByRole('tab', { name: 'Build Details' }).click();
await page.getByText('Pending').first().waitFor();
// Create a build output
await loadTab(page, 'Incomplete Outputs');
await page
.getByRole('button', { name: 'action-button-add-build-output' })
.click();
await page
.getByRole('textbox', { name: 'text-field-batch_code' })
.fill('BATCH-001');
await page.getByRole('button', { name: 'Submit' }).click();
// Cancel (delete) the build output
const cell = await page.getByRole('cell', { name: 'BATCH-001' }).first();
await clickOnRowMenu(cell);
await page.getByRole('menuitem', { name: 'Cancel' }).click();
await page.getByRole('button', { name: 'Submit' }).click();
// no more build outputs
await page.getByText('No records found').waitFor();
// Cancel the build
await page.getByRole('button', { name: 'action-menu-build-order-' }).click();
await page
.getByRole('menuitem', { name: 'action-menu-build-order-actions-cancel' })
.click();
await page
.getByRole('switch', { name: 'boolean-field-remove_allocated_stock' })
.click();
await page
.getByRole('switch', { name: 'boolean-field-remove_incomplete_outputs' })
.click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Cancelled').first().waitFor();
});
// Tests for external build orders