Merge commit '749c4715ee022bdb39b500f5c3776c1ee18411e3' into block-notes

This commit is contained in:
Oliver Walters 2026-05-23 23:30:06 +00:00
commit 38e3c86307
26 changed files with 915 additions and 236 deletions

View File

@ -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.

View File

@ -96,6 +96,8 @@ Parts can be locked to prevent them from being modified. This is useful for part
- BOM items cannot be created, edited, or deleted when they are part of a locked assembly
- Parameters linked to a locked part cannot be created, edited or deleted
The part locking functionality can be enabled or disabled globally via the [Part Locking](../settings/global.md#parts) system setting (`PART_ENABLE_LOCKING`). When disabled, the locked state of a part is ignored and all operations are permitted.
## Active Parts
By default, all parts are *Active*. Marking a part as inactive means it is not available for many actions, but the part remains in the database. If a part becomes obsolete, it is recommended that it is marked as inactive, rather than deleting it from the database.

View File

@ -162,6 +162,7 @@ Configuration of label printing:
{{ globalsetting("PART_ALLOW_DUPLICATE_IPN") }}
{{ globalsetting("PART_ALLOW_EDIT_IPN") }}
{{ globalsetting("PART_ALLOW_DELETE_FROM_ASSEMBLY") }}
{{ globalsetting("PART_ENABLE_LOCKING") }}
{{ globalsetting("PART_ENABLE_REVISION") }}
{{ globalsetting("PART_REVISION_ASSEMBLY_ONLY") }}
{{ globalsetting("PART_NAME_FORMAT") }}

View File

@ -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

View File

@ -37,7 +37,9 @@ def isAppLoaded(app_name: str) -> bool:
def isInTestMode():
"""Returns True if the database is in testing mode."""
return 'test' in sys.argv or sys.argv[0].endswith('pytest')
return any(x in sys.argv for x in ['test', 'pytest']) or sys.argv[0].endswith(
'pytest'
)
def isWaitingForDatabase():

View File

@ -178,7 +178,7 @@ def check_existing_task(taskname, group: str, *args, **kwargs) -> Optional[str]:
# Iterate through all available tasks, with the most recent first
for task in OrmQ.objects.all().order_by('-id'):
if task.func() != taskname and task.task['func'] != taskname:
if task.func() != taskname and task.task.get('func') != taskname:
# Task does not match
continue

View File

@ -735,14 +735,81 @@ class BuildOutputScrap(BuildOrderContextMixin, CreateAPI):
ctx['to_complete'] = False
return ctx
@extend_schema(responses={200: common.serializers.TaskDetailSerializer})
def post(self, *args, **kwargs):
"""Override POST to offload scrapping to the background worker."""
from build.tasks import scrap_build_outputs
from InvenTree.tasks import offload_task
build = self.get_build()
serializer = self.get_serializer(data=self.request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
task_id = offload_task(
scrap_build_outputs,
build.pk,
outputs=[
{
'output_id': item['output'].pk,
'quantity': float(item['quantity'])
if item.get('quantity') is not None
else None,
}
for item in data['outputs']
],
location_id=data['location'].pk,
notes=data.get('notes', ''),
discard_allocations=data.get('discard_allocations', False),
user_id=self.request.user.pk,
group='build',
)
response = common.serializers.TaskDetailSerializer.from_task(task_id).data
return Response(response, status=response['http_status'])
class BuildOutputComplete(BuildOrderContextMixin, CreateAPI):
"""API endpoint for completing build outputs."""
queryset = Build.objects.none()
serializer_class = build.serializers.BuildOutputCompleteSerializer
@extend_schema(responses={200: common.serializers.TaskDetailSerializer})
def post(self, *args, **kwargs):
"""Override POST to offload build output completion to the background worker."""
from build.tasks import complete_build_outputs
from InvenTree.tasks import offload_task
build = self.get_build()
serializer = self.get_serializer(data=self.request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
location = data.get('location')
task_id = offload_task(
complete_build_outputs,
build.pk,
outputs=[
{
'output_id': item['output'].pk,
'quantity': float(item['quantity'])
if item.get('quantity') is not None
else None,
}
for item in data['outputs']
],
location_id=location.pk if location else None,
status=data.get('status_custom_key'),
notes=data.get('notes', ''),
user_id=self.request.user.pk,
group='build',
)
response = common.serializers.TaskDetailSerializer.from_task(task_id).data
return Response(response, status=response['http_status'])
class BuildOutputDelete(BuildOrderContextMixin, CreateAPI):
"""API endpoint for deleting multiple build outputs."""
@ -750,15 +817,33 @@ class BuildOutputDelete(BuildOrderContextMixin, CreateAPI):
def get_serializer_context(self):
"""Add extra context information to the endpoint serializer."""
ctx = super().get_serializer_context()
ctx['to_complete'] = False
return ctx
queryset = Build.objects.none()
serializer_class = build.serializers.BuildOutputDeleteSerializer
@extend_schema(responses={200: common.serializers.TaskDetailSerializer})
def post(self, *args, **kwargs):
"""Override POST to offload build output deletion to the background worker."""
from build.tasks import delete_build_outputs
from InvenTree.tasks import offload_task
build = self.get_build()
serializer = self.get_serializer(data=self.request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
task_id = offload_task(
delete_build_outputs,
build.pk,
output_ids=[item['output'].pk for item in data['outputs']],
group='build',
)
response = common.serializers.TaskDetailSerializer.from_task(task_id).data
return Response(response, status=response['http_status'])
class BuildFinish(BuildOrderContextMixin, CreateAPI):
"""API endpoint for marking a build as finished (completed)."""

View File

@ -37,7 +37,6 @@ from build.validators import (
validate_build_order_reference,
)
from common.models import ProjectCode
from common.notifications import InvenTreeNotificationBodies, trigger_notification
from common.settings import (
get_global_setting,
prevent_build_output_complete_on_incompleted_tests,
@ -652,7 +651,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.
@ -663,7 +661,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):
@ -703,66 +701,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.
@ -840,26 +792,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()
@ -868,17 +809,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.
@ -1084,7 +1014,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

View File

@ -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."""

View File

@ -100,6 +100,214 @@ def complete_build_allocations(build_id: int, user_id: int):
build_order.complete_allocations(user)
@tracer.start_as_current_span('delete_build_outputs')
def delete_build_outputs(build_id: int, output_ids: list, **kwargs):
"""Delete (cancel) specified build outputs for a BuildOrder.
Arguments:
build_id: The ID of the BuildOrder
output_ids: List of StockItem PKs to delete
"""
from build.models import Build
from stock.models import StockItem
build = Build.objects.get(pk=build_id)
with transaction.atomic():
for output_id in output_ids:
output = StockItem.objects.filter(pk=output_id).first()
if output:
build.delete_output(output)
@tracer.start_as_current_span('scrap_build_outputs')
def scrap_build_outputs(
build_id: int,
outputs: list,
location_id: int,
notes: str = '',
discard_allocations: bool = False,
user_id: int | None = None,
**kwargs,
):
"""Scrap specified build outputs for a BuildOrder.
Arguments:
build_id: The ID of the BuildOrder
outputs: List of dicts with 'output_id' and 'quantity'
location_id: PK of the destination StockLocation
notes: Reason for scrapping
discard_allocations: If True, discard (not consume) allocations
user_id: PK of the user initiating the action
"""
from build.models import Build
from stock.models import StockItem, StockLocation
build = Build.objects.get(pk=build_id)
location = StockLocation.objects.get(pk=location_id)
user = User.objects.filter(pk=user_id).first() if user_id else None
with transaction.atomic():
for item in outputs:
output = StockItem.objects.filter(pk=item['output_id']).first()
if output:
build.scrap_build_output(
output,
item.get('quantity'),
location,
user=user,
notes=notes,
discard_allocations=discard_allocations,
)
@tracer.start_as_current_span('complete_build_outputs')
def complete_build_outputs(
build_id: int,
outputs: list,
location_id: int | None,
status: int,
notes: str = '',
user_id: int | None = None,
**kwargs,
):
"""Complete specified build outputs for a BuildOrder.
Arguments:
build_id: The ID of the BuildOrder
outputs: List of dicts with 'output_id' and optional 'quantity'
location_id: PK of the destination StockLocation (or None)
status: Stock status code to assign to completed outputs
notes: Completion notes
user_id: PK of the user initiating the action
"""
from build.models import Build
from stock.models import StockItem, StockLocation
build = Build.objects.get(pk=build_id)
location = (
StockLocation.objects.filter(pk=location_id).first() if location_id else None
)
user = User.objects.filter(pk=user_id).first() if user_id else None
required_tests = build.part.getRequiredTests()
with transaction.atomic():
for item in outputs:
output = StockItem.objects.filter(pk=item['output_id']).first()
if output:
build.complete_build_output(
output,
user,
quantity=item.get('quantity'),
location=location,
status=status,
notes=notes,
required_tests=required_tests,
)
@tracer.start_as_current_span('cancel_build')
def cancel_build(
build_id: int,
user_id: int,
remove_allocated_stock: bool = False,
remove_incomplete_outputs: bool = False,
):
"""Tasks to run after a BuildOrder is cancelled.
Arguments:
build_id: The ID of the BuildOrder which has been cancelled
user_id: The ID of the user who cancelled the BuildOrder
remove_allocated_stock: If True, consume any allocated stock
remove_incomplete_outputs: If True, delete any incomplete build outputs
"""
from build.models import Build
build = Build.objects.get(pk=build_id)
if remove_allocated_stock:
complete_build_allocations(build_id, user_id)
else:
build.allocated_stock.all().delete()
if remove_incomplete_outputs:
build.build_outputs.filter(is_building=True).delete()
# Notify users that the order has been canceled
InvenTree.helpers_model.notify_responsible(
build,
Build,
exclude=build.issued_by,
content=common.notifications.InvenTreeNotificationBodies.OrderCanceled,
extra_users=build.part.get_subscribers(),
)
trigger_event(BuildEvents.CANCELLED, id=build.pk)
@tracer.start_as_current_span('complete_build')
def complete_build(build_id: int, user_id: int, trim_allocated_stock: bool = False):
"""Tasks to run after a BuildOrder is completed.
Arguments:
build_id: The ID of the BuildOrder which has been completed
user_id: The ID of the user who completed the BuildOrder
trim_allocated_stock: If True, trim any allocated stock which was not consumed
"""
from build.models import Build
build = Build.objects.get(pk=build_id)
user = User.objects.filter(pk=user_id).first() if user_id else None
if trim_allocated_stock:
build.trim_allocated_stock()
# Complete any remaining allocations for this build order
complete_build_allocations(build_id, user_id)
# Register an event
trigger_event(BuildEvents.COMPLETED, id=build.pk)
# Notify users that this build has been completed
targets = [build.issued_by, build.responsible]
# Also inform anyone subscribed to the assembly part
targets.extend(build.part.get_subscribers())
# Notify those users interested in the parent build
if build.parent:
targets.append(build.parent.issued_by)
targets.append(build.parent.responsible)
# Notify users if this build points to a sales order
if build.sales_order:
targets.append(build.sales_order.created_by)
targets.append(build.sales_order.responsible)
name = _(f'Build order {build} has been completed')
context = {
'build': build,
'name': name,
'slug': 'build.completed',
'message': _('A build order has been completed'),
'link': InvenTree.helpers_model.construct_absolute_url(
build.get_absolute_url()
),
'template': {'html': 'email/build_order_completed.html', 'subject': name},
}
common.notifications.trigger_notification(
build,
'build.completed',
targets=targets,
context=context,
target_exclude=[user],
)
@tracer.start_as_current_span('update_build_order_lines')
def update_build_order_lines(bom_item_pk: int):
"""Update all BuildOrderLineItem objects which reference a particular BomItem.

View File

@ -155,7 +155,7 @@ class BuildTest(BuildAPITest):
self.post(
reverse('api-build-output-complete', kwargs={'pk': 99999}),
{},
expected_code=400,
expected_code=404,
)
data = self.post(self.url, {}, expected_code=400).data
@ -226,8 +226,8 @@ class BuildTest(BuildAPITest):
'location': 1,
'status': StockStatus.ATTENTION.value,
},
expected_code=201,
max_query_count=400,
expected_code=200,
max_query_count=450,
)
self.assertEqual(self.build.incomplete_outputs.count(), 0)
@ -446,7 +446,7 @@ class BuildTest(BuildAPITest):
self.post(
delete_url,
{'outputs': [{'output': output.pk} for output in outputs[1:3]]},
expected_code=201,
expected_code=200,
)
# Two build outputs have been removed
@ -473,7 +473,7 @@ class BuildTest(BuildAPITest):
'outputs': [{'output': output.pk} for output in outputs[3:]],
'location': 4,
},
expected_code=201,
expected_code=200,
)
# Check that the outputs have been completed
@ -1353,7 +1353,7 @@ class BuildOutputCreateTest(BuildAPITest):
url, data={'quantity': 5, 'serial_numbers': '1,2,3-5'}, expected_code=201
)
# Build outputs have incdeased
# Build outputs have increased
self.assertEqual(n_outputs + 5, build.output_count)
# Stock items have increased
@ -1466,7 +1466,7 @@ class BuildOutputScrapTest(BuildAPITest):
'location': 1,
'notes': 'Should succeed',
},
expected_code=201,
expected_code=200,
)
# There should still be three outputs associated with this build
@ -1534,7 +1534,7 @@ class BuildOutputScrapTest(BuildAPITest):
# Partially complete the output (with a valid quantity)
data['outputs'][0]['quantity'] = 4
self.post(url, data, expected_code=201)
self.post(url, data, expected_code=200)
build.refresh_from_db()
output.refresh_from_db()
@ -1571,13 +1571,22 @@ class BuildOutputCancelTest(BuildAPITest):
set_global_setting('STOCK_ALLOW_DELETE_SERIALIZED', True)
url = reverse('api-build-output-delete', kwargs={'pk': build.pk})
self.post(url, data={'outputs': [{'output': output_ids[0]}]}, expected_code=201)
self.post(url, data={'outputs': [{'output': output_ids[0]}]}, expected_code=200)
# Prevent deletion of serialized stock items, and try again
# Note that this should still succeed, independent of the global setting
set_global_setting('STOCK_ALLOW_DELETE_SERIALIZED', False)
self.post(url, data={'outputs': [{'output': output_ids[1]}]}, expected_code=201)
response = self.post(
url, data={'outputs': [{'output': output_ids[1]}]}, expected_code=200
)
# Response should be the task info - the cancellation is performed asynchronously
self.assertIn('task_id', response.data)
self.assertFalse(response.data['exists'])
self.assertFalse(response.data['pending'])
self.assertTrue(response.data['complete'])
self.assertTrue(response.data['success'])
# The outputs should have been scrapped
self.assertEqual(build.build_outputs.count(), N)

View File

@ -27,6 +27,7 @@ from InvenTree.unit_test import (
from order.models import PurchaseOrder, PurchaseOrderLineItem
from part.models import BomItem, BomItemSubstitute, Part, PartTestTemplate
from stock.models import StockItem, StockItemTestResult, StockLocation
from stock.status_codes import StockStatus
from users.models import Owner
logger = structlog.get_logger('inventree')
@ -557,14 +558,29 @@ class BuildTest(BuildTestBase):
bo.clean()
def test_cancel(self):
"""Test cancellation of the build."""
# TODO
"""
self.allocate_stock(50, 50, 200, self.output_1)
self.build.cancel_build(None)
"""Test build cancellation: status is updated and allocations are removed by default."""
self.build.issue_build()
self.assertEqual(BuildItem.objects.count(), 0)
"""
self.allocate_stock(None, {self.stock_1_2: 50})
self.assertGreater(self.build.allocated_stock.count(), 0)
initial_output_count = self.build.build_outputs.filter(is_building=True).count()
self.assertGreater(initial_output_count, 0)
self.build.cancel_build(None)
self.build.refresh_from_db()
self.assertEqual(self.build.status, BuildStatus.CANCELLED)
# Allocations removed (but stock not consumed) by default
self.assertEqual(self.build.allocated_stock.count(), 0)
self.assertIsNone(StockItem.objects.get(pk=self.stock_1_2.pk).consumed_by)
# Incomplete outputs preserved by default (remove_incomplete_outputs=False)
self.assertEqual(
self.build.build_outputs.filter(is_building=True).count(),
initial_output_count,
)
def test_complete(self):
"""Test completion of a build output."""
@ -1107,3 +1123,276 @@ class ExternalBuildTest(InvenTreeAPITestCase):
# Filter by 'not external'
response = self.get(url, {'external': 'false'})
self.assertEqual(len(response.data), 2)
class BuildTaskTests(BuildTestBase):
"""Direct unit tests for the background task functions in build/tasks.py.
These tests call task functions directly (synchronously) to verify the
business logic they encapsulate, independently of the API and offload mechanism.
"""
def setUp(self):
"""Create a stock location available to all task tests."""
super().setUp()
self.location = StockLocation.objects.create(name='Task Test Location')
def allocate_stock(self, output, allocations):
"""Create BuildItem allocations against self.build for the given output."""
items_to_create = []
for item, quantity in allocations.items():
line = BuildLine.objects.filter(
build=self.build, bom_item__sub_part=item.part
).first()
items_to_create.append(
BuildItem(
build_line=line,
stock_item=item,
quantity=quantity,
install_into=output,
)
)
BuildItem.objects.bulk_create(items_to_create)
def _setup_complete_build(self):
"""Helper: allocate stock fully and complete all outputs so the build is ready to complete."""
self.stock_1_1.quantity = 1000
self.stock_1_1.save()
self.stock_2_1.quantity = 30
self.stock_2_1.save()
self.build.issue_build()
# Allocate untracked parts
self.allocate_stock(
None, {self.stock_1_1: 50, self.stock_1_2: 10, self.stock_2_1: 30}
)
# Allocate tracked parts to each output
self.allocate_stock(self.output_1, {self.stock_3_1: 6})
self.allocate_stock(self.output_2, {self.stock_3_1: 14})
self.build.complete_build_output(self.output_1, None)
self.build.complete_build_output(self.output_2, None)
# -----------------------------------------------------------------------
# cancel_build task
# -----------------------------------------------------------------------
def test_cancel_task_discards_allocations(self):
"""cancel_build with remove_allocated_stock=False: allocations deleted, stock not consumed."""
self.build.issue_build()
self.allocate_stock(None, {self.stock_1_2: 50})
self.assertGreater(self.build.allocated_stock.count(), 0)
build.tasks.cancel_build(
self.build.pk, self.user.pk, remove_allocated_stock=False
)
# BuildItem rows gone
self.assertEqual(self.build.allocated_stock.count(), 0)
# Stock item was NOT consumed
self.assertIsNone(StockItem.objects.get(pk=self.stock_1_2.pk).consumed_by)
def test_cancel_task_consumes_allocations(self):
"""cancel_build with remove_allocated_stock=True: stock items are marked consumed."""
self.build.issue_build()
self.allocate_stock(None, {self.stock_1_2: 50})
build.tasks.cancel_build(
self.build.pk, self.user.pk, remove_allocated_stock=True
)
# All BuildItem rows gone
self.assertEqual(self.build.allocated_stock.count(), 0)
# The allocated (non-trackable) stock was consumed
self.assertGreater(self.build.consumed_stock.count(), 0)
def test_cancel_task_removes_incomplete_outputs(self):
"""cancel_build with remove_incomplete_outputs=True: in-progress outputs are deleted."""
self.build.issue_build()
initial_count = self.build.build_outputs.filter(is_building=True).count()
self.assertGreater(initial_count, 0)
build.tasks.cancel_build(
self.build.pk, self.user.pk, remove_incomplete_outputs=True
)
self.assertEqual(self.build.build_outputs.filter(is_building=True).count(), 0)
def test_cancel_task_preserves_incomplete_outputs(self):
"""cancel_build with remove_incomplete_outputs=False: in-progress outputs are kept."""
self.build.issue_build()
initial_count = self.build.build_outputs.filter(is_building=True).count()
self.assertGreater(initial_count, 0)
build.tasks.cancel_build(
self.build.pk, self.user.pk, remove_incomplete_outputs=False
)
self.assertEqual(
self.build.build_outputs.filter(is_building=True).count(), initial_count
)
# -----------------------------------------------------------------------
# complete_build task
# -----------------------------------------------------------------------
@override_settings(
TESTING_TABLE_EVENTS=True,
PLUGIN_TESTING_EVENTS=True,
PLUGIN_TESTING_EVENTS_ASYNC=True,
)
def test_complete_build_task_triggers_event(self):
"""complete_build task fires the BuildEvents.COMPLETED event."""
from django_q.models import OrmQ
from build.events import BuildEvents
set_global_setting('ENABLE_PLUGINS_EVENTS', True)
OrmQ.objects.all().delete()
self._setup_complete_build()
self.build.complete_build(self.user)
task = findOffloadedEvent(BuildEvents.COMPLETED, matching_kwargs=['id'])
self.assertIsNotNone(task)
self.assertEqual(task.kwargs()['id'], self.build.pk)
set_global_setting('ENABLE_PLUGINS_EVENTS', False)
def test_complete_build_task_trim_stock(self):
"""complete_build with trim_allocated_stock=True removes over-allocations before consuming."""
self.stock_1_2.quantity = 100
self.stock_1_2.save()
self.stock_2_1.quantity = 30
self.stock_2_1.save()
self.build.issue_build()
# Over-allocate sub_part_1: need 50, allocate 100
self.allocate_stock(None, {self.stock_1_2: 100, self.stock_2_1: 30})
self.allocate_stock(self.output_1, {self.stock_3_1: 6})
self.allocate_stock(self.output_2, {self.stock_3_1: 14})
self.assertTrue(self.build.is_overallocated())
self.build.complete_build_output(self.output_1, None)
self.build.complete_build_output(self.output_2, None)
self.assertTrue(self.build.can_complete)
self.build.complete_build(self.user, trim_allocated_stock=True)
self.build.refresh_from_db()
self.assertEqual(self.build.status, BuildStatus.COMPLETE)
# Only 50 units of sub_part_1 should have been consumed (not 100)
consumed_qty = StockItem.objects.filter(
consumed_by=self.build, part=self.sub_part_1
).aggregate(total=Sum('quantity'))['total']
self.assertEqual(consumed_qty, 50)
# -----------------------------------------------------------------------
# delete_build_outputs task
# -----------------------------------------------------------------------
def test_delete_build_outputs_skips_missing_id(self):
"""delete_build_outputs silently skips nonexistent output IDs and deletes valid ones."""
from build.tasks import delete_build_outputs
# Create an output directly to avoid serial-number requirements on the trackable assembly
output = StockItem.objects.create(
part=self.assembly, quantity=3, is_building=True, build=self.build
)
real_id = output.pk
# Mix a valid ID with a nonexistent one — must not raise
delete_build_outputs(self.build.pk, [real_id, 99999])
self.assertFalse(StockItem.objects.filter(pk=real_id).exists())
# -----------------------------------------------------------------------
# scrap_build_outputs task
# -----------------------------------------------------------------------
def test_scrap_build_outputs_discard_allocations(self):
"""scrap_build_outputs with discard_allocations=True removes allocations without consuming stock."""
from build.tasks import scrap_build_outputs
self.build.issue_build()
# Allocate tracked stock to output_1
self.allocate_stock(self.output_1, {self.stock_3_1: 6})
self.assertGreater(self.output_1.items_to_install.count(), 0)
scrap_build_outputs(
self.build.pk,
[{'output_id': self.output_1.pk, 'quantity': self.output_1.quantity}],
location_id=self.location.pk,
notes='discard test',
discard_allocations=True,
user_id=None,
)
self.output_1.refresh_from_db()
self.assertEqual(self.output_1.status, StockStatus.REJECTED.value)
self.assertFalse(self.output_1.is_building)
# Allocation rows should be gone
self.assertEqual(self.output_1.items_to_install.count(), 0)
# Stock was discarded (not consumed or installed)
self.stock_3_1.refresh_from_db()
self.assertIsNone(self.stock_3_1.consumed_by)
self.assertIsNone(self.stock_3_1.belongs_to)
def test_scrap_build_outputs_consume_allocations(self):
"""scrap_build_outputs with discard_allocations=False (default) consumes/installs stock."""
from build.tasks import scrap_build_outputs
self.build.issue_build()
self.allocate_stock(self.output_1, {self.stock_3_1: 6})
scrap_build_outputs(
self.build.pk,
[{'output_id': self.output_1.pk, 'quantity': self.output_1.quantity}],
location_id=self.location.pk,
notes='consume test',
discard_allocations=False,
user_id=None,
)
self.output_1.refresh_from_db()
self.assertEqual(self.output_1.status, StockStatus.REJECTED.value)
self.assertFalse(self.output_1.is_building)
# complete_allocation splits stock_3_1 and installs the split piece into output_1
# (stock_3_1 quantity=1000, only 6 allocated, so a child item is created)
self.assertTrue(
StockItem.objects.filter(belongs_to=self.output_1).exists(),
'Expected a tracked stock item to be installed into the output',
)
# -----------------------------------------------------------------------
# complete_build_outputs task
# -----------------------------------------------------------------------
def test_complete_build_outputs_with_status_none(self):
"""complete_build_outputs with status=None falls back to StockStatus.OK in the model."""
from build.tasks import complete_build_outputs
self.build.issue_build()
# Create output directly to avoid serial-number requirements on the trackable assembly
output = StockItem.objects.create(
part=self.assembly, quantity=5, is_building=True, build=self.build
)
complete_build_outputs(
self.build.pk,
[{'output_id': output.pk}],
location_id=self.location.pk,
status=None,
notes='status none test',
user_id=None,
)
output.refresh_from_db()
self.assertFalse(output.is_building)
# status=None should resolve to StockStatus.OK (the model default)
self.assertEqual(output.status, StockStatus.OK.value)

View File

@ -402,6 +402,12 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'choices': barcode_plugins,
'default': 'inventreebarcode',
},
'PART_ENABLE_LOCKING': {
'name': _('Part Locking'),
'description': _('Enable locking of parts to prevent modification'),
'validator': bool,
'default': True,
},
'PART_ENABLE_REVISION': {
'name': _('Part Revisions'),
'description': _('Enable revision field for Part'),

View File

@ -571,13 +571,13 @@ class Part(
}
def check_parameter_delete(self, parameter):
"""Custom delete check for Paramteter instances associated with this Part."""
if self.locked:
"""Custom delete check for Parameter instances associated with this Part."""
if self.locked and get_global_setting('PART_ENABLE_LOCKING'):
raise ValidationError(_('Cannot delete parameters of a locked part'))
def check_parameter_save(self, parameter):
"""Custom save check for Parameter instances associated with this Part."""
if self.locked:
if self.locked and get_global_setting('PART_ENABLE_LOCKING'):
raise ValidationError(_('Cannot modify parameters of a locked part'))
def delete(self, **kwargs):
@ -588,7 +588,7 @@ class Part(
- The part is still active
- The part is used in a BOM for a different part.
"""
if self.locked:
if self.locked and get_global_setting('PART_ENABLE_LOCKING'):
raise ValidationError(_('Cannot delete this part as it is locked'))
if self.active:
@ -4067,7 +4067,8 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
Raises:
ValidationError: If the assembly is locked
"""
# TODO: Perhaps control this with a global setting?
if not get_global_setting('PART_ENABLE_LOCKING'):
return
if assembly.locked:
raise ValidationError(_('BOM item cannot be modified - assembly is locked'))

View File

@ -8,6 +8,7 @@ from django.test import TestCase
import build.models
import stock.models
from common.settings import set_global_setting
from .models import BomItem, BomItemSubstitute, Part
@ -391,6 +392,44 @@ class BomItemTest(TestCase):
# Delete the new BOM item
bom_item.delete()
def test_locked_assembly_locking_disabled(self):
"""Test that a locked assembly is not enforced when PART_ENABLE_LOCKING is disabled."""
assembly = Part.objects.create(
name='Assembly3', description='An assembly part', assembly=True
)
sub_part = Part.objects.create(
name='SubPart2', description='A sub-part', component=True
)
bom_item = BomItem.objects.create(part=assembly, sub_part=sub_part, quantity=1)
assembly.locked = True
assembly.save()
# With locking enabled (default), editing is blocked
with self.assertRaises(django_exceptions.ValidationError):
bom_item.quantity = 5
bom_item.save()
# Disable locking globally — all BOM operations should now be allowed
set_global_setting('PART_ENABLE_LOCKING', False)
bom_item.quantity = 5
bom_item.save()
BomItem.objects.create(part=assembly, sub_part=sub_part, quantity=2)
bom_item.delete()
# Re-enable for other tests
set_global_setting('PART_ENABLE_LOCKING', True)
# Confirm locking is enforced again
bom_item2 = BomItem.objects.get(part=assembly, sub_part=sub_part, quantity=2)
with self.assertRaises(django_exceptions.ValidationError):
bom_item2.quantity = 99
bom_item2.save()
def test_bom_validated(self):
"""Test for caching of 'bom_validated' property."""
from part.tasks import validate_bom

View File

@ -6,6 +6,7 @@ from django.test import TestCase, TransactionTestCase
from django.urls import reverse
from common.models import InvenTreeSetting, Parameter, ParameterTemplate
from common.settings import set_global_setting
from InvenTree.unit_test import InvenTreeAPITestCase
from .models import Part, PartCategory, PartCategoryParameterTemplate
@ -141,6 +142,43 @@ class TestParams(TestCase):
# And we can delete the parameter
parameter.delete()
def test_locked_part_locking_disabled(self):
"""Test that parameter restrictions are lifted when PART_ENABLE_LOCKING is disabled."""
part = Part.objects.create(
name='Test Part Lock Override',
description='Part for testing global locking override',
category=PartCategory.objects.first(),
)
parameter = Parameter.objects.create(
content_object=part, template=ParameterTemplate.objects.first(), data='100'
)
part.locked = True
part.save()
# With locking enabled (default), editing and deletion are blocked
with self.assertRaises(django_exceptions.ValidationError):
parameter.data = '200'
parameter.save()
with self.assertRaises(django_exceptions.ValidationError):
parameter.delete()
# Disable locking globally — parameter operations should now succeed
set_global_setting('PART_ENABLE_LOCKING', False)
parameter.data = '200'
parameter.save()
self.assertEqual(parameter.data, '200')
# Re-enable locking — editing should be blocked again
set_global_setting('PART_ENABLE_LOCKING', True)
with self.assertRaises(django_exceptions.ValidationError):
parameter.data = '300'
parameter.save()
class TestCategoryTemplates(TransactionTestCase):
"""Test class for PartCategoryParameterTemplate model."""

View File

@ -353,6 +353,28 @@ class PartTest(TestCase):
part.delete()
def test_delete_locking_disabled(self):
"""Test that a locked part can be deleted when PART_ENABLE_LOCKING is disabled."""
part = Part.objects.create(
name='Locked Part Test',
description='Part for testing locking override',
active=False,
)
part.locked = True
part.save()
# With locking enabled (default), deletion of a locked part raises an error
with self.assertRaises(ValidationError):
part.delete()
# Disable locking globally — locked part should now be deletable
set_global_setting('PART_ENABLE_LOCKING', False)
part.delete()
# Re-enable for other tests
set_global_setting('PART_ENABLE_LOCKING', True)
def test_revisions(self):
"""Test the 'revision' and 'revision_of' field."""
template = Part.objects.create(

View File

@ -385,7 +385,7 @@ export function useCompleteBuildOutputsForm({
title: t`Complete Build Outputs`,
fields: buildOutputCompleteFields,
onFormSuccess: onFormSuccess,
successMessage: t`Build outputs have been completed`,
successMessage: null,
size: '80%'
});
}
@ -466,7 +466,7 @@ export function useScrapBuildOutputsForm({
),
fields: buildOutputScrapFields,
onFormSuccess: onFormSuccess,
successMessage: t`Build outputs have been scrapped`,
successMessage: null,
size: '80%'
});
}
@ -527,7 +527,7 @@ export function useCancelBuildOutputsForm({
),
fields: buildOutputCancelFields,
onFormSuccess: onFormSuccess,
successMessage: t`Build outputs have been cancelled`,
successMessage: null,
size: '80%'
});
}

View File

@ -188,6 +188,11 @@ export function usePartFields({
delete fields['default_expiry'];
}
// Remove "locked" field if locking not enabled
if (!globalSettings.isSet('PART_ENABLE_LOCKING')) {
delete fields['locked'];
}
if (create) {
delete fields['starred'];
}

View File

@ -212,6 +212,7 @@ export default function SystemSettings() {
'PART_ALLOW_DUPLICATE_IPN',
'PART_ALLOW_EDIT_IPN',
'PART_ALLOW_DELETE_FROM_ASSEMBLY',
'PART_ENABLE_LOCKING',
'PART_ENABLE_REVISION',
'PART_REVISION_ASSEMBLY_ONLY',
'PART_SHOW_RELATED',

View File

@ -202,6 +202,11 @@ export default function PartDetail() {
refetchOnMount: true
});
const lockingEnabled = useMemo(
() => globalSettings.isSet('PART_ENABLE_LOCKING'),
[globalSettings]
);
const revisionsEnabled = useMemo(
() => globalSettings.isSet('PART_ENABLE_REVISION'),
[globalSettings]
@ -808,7 +813,12 @@ export default function PartDetail() {
icon: <IconTestPipe />,
hidden: !part.testable,
content: part?.pk ? (
<PartTestTemplateTable partId={part?.pk} partLocked={part.locked} />
<PartTestTemplateTable
partId={part?.pk}
partLocked={
globalSettings.isSet('PART_ENABLE_LOCKING') && part?.locked
}
/>
) : (
<Skeleton />
)
@ -836,7 +846,7 @@ export default function PartDetail() {
icon: <IconListDetails />,
content: (
<>
{part.locked && (
{lockingEnabled && part.locked && (
<Alert
title={t`Part is Locked`}
color='orange'
@ -849,7 +859,7 @@ export default function PartDetail() {
<ParameterTable
modelType={ModelType.part}
modelId={part?.pk}
allowEdit={part?.locked != true}
allowEdit={!lockingEnabled || part?.locked != true}
/>
</>
)
@ -1149,20 +1159,22 @@ export default function PartDetail() {
<PageDetail
title={`${t`Part`}: ${part.full_name}`}
icon={
<ActionIcon
aria-label='part-lock-icon'
variant='transparent'
disabled={!user.hasChangeRole(UserRoles.part)}
onClick={() => {
api
.patch(apiUrl(ApiEndpoints.part_list, part.pk), {
locked: !part.locked
})
.then(refreshInstance);
}}
>
{part?.locked ? <IconLock /> : <IconLockOpen />}
</ActionIcon>
lockingEnabled ? (
<ActionIcon
aria-label='part-lock-icon'
variant='transparent'
disabled={!user.hasChangeRole(UserRoles.part)}
onClick={() => {
api
.patch(apiUrl(ApiEndpoints.part_list, part.pk), {
locked: !part.locked
})
.then(refreshInstance);
}}
>
{part?.locked ? <IconLock /> : <IconLockOpen />}
</ActionIcon>
) : undefined
}
subtitle={part.description}
imageUrl={part.image}

View File

@ -52,6 +52,8 @@ export function RenderPartColumn({
part: any;
full_name?: boolean;
}) {
const globalSettings = useGlobalSettingsState.getState();
if (!part) {
return <Skeleton />;
}
@ -69,7 +71,7 @@ export function RenderPartColumn({
<IconExclamationCircle color='red' size={16} />
</Tooltip>
)}
{part?.locked && (
{globalSettings.isSet('PART_ENABLE_LOCKING') && part?.locked && (
<Tooltip label={t`Part is Locked`}>
<IconLock size={16} />
</Tooltip>

View File

@ -40,7 +40,10 @@ import {
useEditApiFormModal
} from '../../hooks/UseForm';
import { useImporterState } from '../../states/ImporterState';
import { useUserSettingsState } from '../../states/SettingsStates';
import {
useGlobalSettingsState,
useUserSettingsState
} from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState';
import {
BooleanColumn,
@ -91,6 +94,13 @@ export function BomTable({
const [isEditing, setIsEditing] = useState<boolean>(false);
const globalSettings = useGlobalSettingsState();
const isLocked = useMemo(
() => globalSettings.isSet('PART_ENABLE_LOCKING') && (partLocked ?? false),
[globalSettings, partLocked]
);
const userSettings = useUserSettingsState();
const tableColumns: TableColumn[] = useMemo(() => {
@ -605,16 +615,14 @@ export function BomTable({
title: t`Validate BOM Line`,
color: 'green',
hidden:
partLocked ||
record.validated ||
!user.hasChangeRole(UserRoles.bom),
isLocked || record.validated || !user.hasChangeRole(UserRoles.bom),
icon: <IconCircleCheck />,
onClick: () => {
validateBomItem(record);
}
},
RowEditAction({
hidden: partLocked || !user.hasChangeRole(UserRoles.bom),
hidden: isLocked || !user.hasChangeRole(UserRoles.bom),
onClick: () => {
setSelectedBomItem(record);
editBomItem.open();
@ -623,7 +631,7 @@ export function BomTable({
{
title: t`Edit Substitutes`,
color: 'blue',
hidden: partLocked || !user.hasAddRole(UserRoles.bom),
hidden: isLocked || !user.hasAddRole(UserRoles.bom),
icon: <IconSwitch3 />,
onClick: () => {
setSelectedBomItem(record);
@ -631,7 +639,7 @@ export function BomTable({
}
},
RowDeleteAction({
hidden: partLocked || !user.hasDeleteRole(UserRoles.bom),
hidden: isLocked || !user.hasDeleteRole(UserRoles.bom),
onClick: () => {
setSelectedBomItem(record);
deleteBomItem.open();
@ -639,7 +647,7 @@ export function BomTable({
})
];
},
[isEditing, partId, partLocked, user]
[isEditing, partId, isLocked, user]
);
const tableActions = useMemo(() => {
@ -649,7 +657,7 @@ export function BomTable({
tooltip={t`Add BOM Items`}
position='bottom-start'
icon={<IconPlus />}
hidden={!isEditing || partLocked || !user.hasAddRole(UserRoles.bom)}
hidden={!isEditing || isLocked || !user.hasAddRole(UserRoles.bom)}
actions={[
{
name: t`Add BOM Item`,
@ -667,7 +675,7 @@ export function BomTable({
/>,
<ActionButton
key='edit-bom'
hidden={partLocked || !user.hasChangeRole(UserRoles.bom) || isEditing}
hidden={isLocked || !user.hasChangeRole(UserRoles.bom) || isEditing}
tooltip={t`Edit BOM`}
icon={<IconEdit />}
onClick={() => {
@ -686,7 +694,7 @@ export function BomTable({
}}
/>
];
}, [isEditing, partLocked, user]);
}, [isEditing, isLocked, user]);
// Row expansion (for displaying subassemblies)
const rowExpansion = subassemblyRowExpansion({ table: table });
@ -699,7 +707,7 @@ export function BomTable({
{deleteBomItem.modal}
{editSubstitutes.modal}
<Stack gap='xs'>
{partLocked && (
{isLocked && (
<Alert
title={t`Part is Locked`}
color='orange'
@ -730,9 +738,9 @@ export function BomTable({
modelField: 'sub_part',
onCellClick: () => {},
rowActions: isEditing ? rowActions : undefined,
enableSelection: isEditing && !partLocked,
enableSelection: isEditing && !isLocked,
enableBulkDelete:
isEditing && !partLocked && user.hasDeleteRole(UserRoles.bom),
isEditing && !isLocked && user.hasDeleteRole(UserRoles.bom),
enableDownload: true,
rowExpansion: isEditing ? undefined : rowExpansion
}}

View File

@ -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();
}
}
});

View File

@ -207,8 +207,7 @@ export function PurchaseOrderLineItemTable({
{
accessor: 'received',
title: t`Received`,
sortable: false,
sortable: true,
render: (record: any) => (
<ProgressBar
progressLabel={true}

View File

@ -38,6 +38,7 @@ test('Build Order - Basic Tests', async ({ browser }) => {
await clearTableFilters(page);
// We have now loaded the "Build Order" table. Check for some expected texts
await page.getByPlaceholder('Search').fill('7');
await page.getByText('On Hold').first().waitFor();
await page.getByText('Pending').first().waitFor();
@ -60,6 +61,7 @@ test('Build Order - Basic Tests', async ({ browser }) => {
await page.getByLabel('breadcrumb-0-manufacturing').click();
// Load a different build order
await page.getByPlaceholder('Search').fill('11');
await page.getByRole('cell', { name: 'BO0011' }).click();
// This build order should be "in production"
@ -654,6 +656,7 @@ test('Build Order - Filters', async ({ browser }) => {
// Check for expected pagination text i.e. (1 - 24 / 24)
// Note: Due to other concurrent tests, the number of build orders may vary
await page.getByText(/1 - \d+ \/ \d+/).waitFor();
await page.getByPlaceholder('Search').fill('23');
await page.getByRole('cell', { name: 'BO0023' }).waitFor();
// Toggle 'Outstanding' filter
@ -665,7 +668,7 @@ test('Build Order - Filters', async ({ browser }) => {
await page.getByRole('textbox', { name: 'table-search-input' }).fill('');
await setTableChoiceFilter(page, 'Outstanding', 'No');
await page.getByText('1 - 6 / 6').waitFor();
await page.getByText(/1 - \d+ \/ \d+/).waitFor();
await clearTableFilters(page);
@ -699,6 +702,40 @@ test('Build Order - Duplicate', async ({ browser }) => {
await page.getByRole('tab', { name: 'Build Details' }).click();
await page.getByText('Pending').first().waitFor();
// Create a build output
await loadTab(page, 'Incomplete Outputs');
await page
.getByRole('button', { name: 'action-button-add-build-output' })
.click();
await page
.getByRole('textbox', { name: 'text-field-batch_code' })
.fill('BATCH-001');
await page.getByRole('button', { name: 'Submit' }).click();
// Cancel (delete) the build output
const cell = await page.getByRole('cell', { name: 'BATCH-001' }).first();
await clickOnRowMenu(cell);
await page.getByRole('menuitem', { name: 'Cancel' }).click();
await page.getByRole('button', { name: 'Submit' }).click();
// no more build outputs
await page.getByText('No records found').waitFor();
// Cancel the build
await page.getByRole('button', { name: 'action-menu-build-order-' }).click();
await page
.getByRole('menuitem', { name: 'action-menu-build-order-actions-cancel' })
.click();
await page
.getByRole('switch', { name: 'boolean-field-remove_allocated_stock' })
.click();
await page
.getByRole('switch', { name: 'boolean-field-remove_incomplete_outputs' })
.click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Cancelled').first().waitFor();
});
// Tests for external build orders