mirror of
https://github.com/inventree/InvenTree.git
synced 2026-05-28 03:49:20 +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:
@@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- [#11990](https://github.com/inventree/InvenTree/pull/11990) build output operations performed via the API now offload the work to a background task, and now return a task ID which can be used to monitor the progress of the task. This allows for better performance and responsiveness when performing build output operations, as the work is performed asynchronously in the background.
|
||||
- [#11825](https://github.com/inventree/InvenTree/pull/11825) adds a new "bom" ruleset and associated permissions for BOM management, separate from the "part" ruleset which remains focused on part management. This allows for more granular control over user permissions, allowing users to have different levels of access to part management and BOM management functionality.
|
||||
- [#11816](https://github.com/inventree/InvenTree/pull/11816) makes the `issued_by` field on the `Build` API read only, and instead sets the `issued_by` field to the current user when a build is created. This change was made to ensure that the `issued_by` field accurately reflects the user who created the build, and to prevent users from setting this field to an arbitrary value when creating or updating a build.
|
||||
|
||||
|
||||
@@ -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,27 +791,16 @@ 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,
|
||||
# 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',
|
||||
):
|
||||
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()
|
||||
|
||||
# Date of 'completion' is the date the build was cancelled
|
||||
self.completion_date = InvenTree.helpers.current_date()
|
||||
self.completed_by = user
|
||||
@@ -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,32 +346,81 @@ 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: () => {
|
||||
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: () => {
|
||||
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: () => {
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const editStockItemFields = useStockFields({
|
||||
|
||||
@@ -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