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 commit3a3ac3bdc7. * Revert "Offload order completion tasks" This reverts commit6066dabe43.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user