diff --git a/docs/docs/assets/images/build/parts_allocated_consumed.png b/docs/docs/assets/images/build/parts_allocated_consumed.png new file mode 100644 index 0000000000..8c0e60a22f Binary files /dev/null and b/docs/docs/assets/images/build/parts_allocated_consumed.png differ diff --git a/docs/docs/manufacturing/allocate.md b/docs/docs/manufacturing/allocate.md index 0293d644c1..f6627f35fe 100644 --- a/docs/docs/manufacturing/allocate.md +++ b/docs/docs/manufacturing/allocate.md @@ -126,6 +126,49 @@ Here we can see that the incomplete build outputs (serial numbers 15 and 14) now !!! note "Example: Tracked Stock" Let's say we have 5 units of "Tracked Part" in stock - with 1 unit allocated to the build output. Once we complete the build output, there will be 4 units of "Tracked Part" in stock, with 1 unit being marked as "installed" within the assembled part +## Consuming Stock + +Allocating stock items to a build order does not immediately remove them from stock. Instead, the stock items are marked as "allocated" against the build order, and are only removed from stock when they are "consumed" by the build order. + +In the *Required Parts* tab, you can see the *consumed* vs *allocated* state of each line item in the BOM: + +{{ image("build/parts_allocated_consumed.png", "Partially allocated and consumed") }} + +Consuming items against the build order can be performed in two ways: + +- Manually, by consuming selected stock allocations against the build order +- Automatically, by completing the build order + +### Manual Consumption + +Manual consuming stock items (before the build order is completed) can be performed at any point after stock has been allocated against the build order. Manual stock consumption may be desired in some situations, for example if the build order is being performed in stages, or to ensure that stock levels are kept up to date. + +Manual consumption of stock items can be performed in the in the following ways: + +#### Required Parts Tab + +Consuming stock items can be performed against BOM line items in the *Required Parts* tab, either against a single line or multiple selected lines: + +- Navigate to the *Required Parts* tab +- Select the individual line items which you wish to consume +- Click the *Consume Stock* button + +#### Allocated Stock Tab + +Consuming stock items can also be performed against the *Allocated Stock* tab, either against a single allocation or multiple allocations: + +- Navigate to the *Allocated Stock* tab +- Select the individual stock allocations which you wish to consume +- Click the *Consume Stock* button + +### Automatic Consumption + +When a build order is completed, all remaining allocated stock items are automatically consumed by the build order. + +### Returning Items to Stock + +Consumed items may be manually returned into stock if required. This can be performed in the *Consumed Stock* tab. + ## Completing a Build !!! warning "Complete Build Outputs" diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 19b161bf37..ba2be6f219 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,15 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 385 +INVENTREE_API_VERSION = 386 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v386 -> 2025-08-11 : https://github.com/inventree/InvenTree/pull/8191 + - Adds "consumed" field to the BuildItem API + - Adds API endpoint to consume stock against a BuildOrder + v385 -> 2025-08-15 : https://github.com/inventree/InvenTree/pull/10174 - Adjust return type of PurchaseOrderReceive API serializer - Now returns list of of the created stock items when receiving diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index 4bd5cce971..1ddeff9fda 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -479,6 +479,14 @@ class BuildLineFilter(rest_filters.FilterSet): return queryset.filter(allocated__gte=F('quantity')) return queryset.filter(allocated__lt=F('quantity')) + consumed = rest_filters.BooleanFilter(label=_('Consumed'), method='filter_consumed') + + def filter_consumed(self, queryset, name, value): + """Filter by whether each BuildLine is fully consumed.""" + if str2bool(value): + return queryset.filter(consumed__gte=F('quantity')) + return queryset.filter(consumed__lt=F('quantity')) + available = rest_filters.BooleanFilter( label=_('Available'), method='filter_available' ) @@ -494,6 +502,7 @@ class BuildLineFilter(rest_filters.FilterSet): """ flt = Q( quantity__lte=F('allocated') + + F('consumed') + F('available_stock') + F('available_substitute_stock') + F('available_variant_stock') @@ -504,7 +513,7 @@ class BuildLineFilter(rest_filters.FilterSet): return queryset.exclude(flt) -class BuildLineEndpoint: +class BuildLineMixin: """Mixin class for BuildLine API endpoints.""" queryset = BuildLine.objects.all() @@ -553,7 +562,7 @@ class BuildLineEndpoint: ) -class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI): +class BuildLineList(BuildLineMixin, DataExportViewMixin, ListCreateAPI): """API endpoint for accessing a list of BuildLine objects.""" filterset_class = BuildLineFilter @@ -562,6 +571,7 @@ class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI): ordering_fields = [ 'part', 'allocated', + 'consumed', 'reference', 'quantity', 'consumable', @@ -605,7 +615,7 @@ class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI): return source_build -class BuildLineDetail(BuildLineEndpoint, RetrieveUpdateDestroyAPI): +class BuildLineDetail(BuildLineMixin, RetrieveUpdateDestroyAPI): """API endpoint for detail view of a BuildLine object.""" def get_source_build(self) -> Build | None: @@ -734,6 +744,13 @@ class BuildAllocate(BuildOrderContextMixin, CreateAPI): serializer_class = build.serializers.BuildAllocationSerializer +class BuildConsume(BuildOrderContextMixin, CreateAPI): + """API endpoint to consume stock against a build order.""" + + queryset = Build.objects.none() + serializer_class = build.serializers.BuildConsumeSerializer + + class BuildIssue(BuildOrderContextMixin, CreateAPI): """API endpoint for issuing a BuildOrder.""" @@ -953,6 +970,7 @@ build_api_urls = [ '/', include([ path('allocate/', BuildAllocate.as_view(), name='api-build-allocate'), + path('consume/', BuildConsume.as_view(), name='api-build-consume'), path( 'auto-allocate/', BuildAutoAllocate.as_view(), diff --git a/src/backend/InvenTree/build/migrations/0058_buildline_consumed.py b/src/backend/InvenTree/build/migrations/0058_buildline_consumed.py new file mode 100644 index 0000000000..70e6af9455 --- /dev/null +++ b/src/backend/InvenTree/build/migrations/0058_buildline_consumed.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.15 on 2024-09-26 10:11 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('build', '0057_build_external'), + ] + + operations = [ + migrations.AddField( + model_name='buildline', + name='consumed', + field=models.DecimalField(decimal_places=5, default=0, help_text='Quantity of consumed stock', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Consumed'), + ), + ] diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index 40c0d6bb2c..cdfe725543 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -1106,7 +1106,7 @@ class Build( # Remove stock for item in items: - item.complete_allocation(user) + item.complete_allocation(user=user) # Delete allocation items.all().delete() @@ -1151,7 +1151,7 @@ class Build( # Complete or discard allocations for build_item in allocated_items: if not discard_allocations: - build_item.complete_allocation(user) + build_item.complete_allocation(user=user) # Delete allocations allocated_items.delete() @@ -1200,7 +1200,7 @@ class Build( for build_item in allocated_items: # Complete the allocation of stock for that item - build_item.complete_allocation(user) + build_item.complete_allocation(user=user) # Delete the BuildItem objects from the database allocated_items.all().delete() @@ -1569,6 +1569,7 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo build: Link to a Build object bom_item: Link to a BomItem object quantity: Number of units required for the Build + consumed: Number of units which have been consumed against this line item """ class Meta: @@ -1614,6 +1615,15 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo help_text=_('Required quantity for build order'), ) + consumed = models.DecimalField( + decimal_places=5, + max_digits=15, + default=0, + validators=[MinValueValidator(0)], + verbose_name=_('Consumed'), + help_text=_('Quantity of consumed stock'), + ) + @property def part(self): """Return the sub_part reference from the link bom_item.""" @@ -1645,6 +1655,10 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo """Return True if this BuildLine is over-allocated.""" return self.allocated_quantity() > self.quantity + def is_fully_consumed(self): + """Return True if this BuildLine is fully consumed.""" + return self.consumed >= self.quantity + class BuildItem(InvenTree.models.InvenTreeMetadataModel): """A BuildItem links multiple StockItem objects to a Build. @@ -1812,20 +1826,36 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel): return self.build_line.bom_item if self.build_line else None @transaction.atomic - def complete_allocation(self, user, notes=''): + def complete_allocation(self, quantity=None, notes='', user=None): """Complete the allocation of this BuildItem into the output stock item. + Arguments: + quantity: The quantity to allocate (default is the full quantity) + notes: Additional notes to add to the transaction + user: The user completing the allocation + - If the referenced part is trackable, the stock item will be *installed* into the build output - If the referenced part is *not* trackable, the stock item will be *consumed* by the build order TODO: This is quite expensive (in terms of number of database hits) - and requires some thought + TODO: Revisit, and refactor! """ + # If the quantity is not provided, use the quantity of this BuildItem + if quantity is None: + quantity = self.quantity + item = self.stock_item + # Ensure we are not allocating more than available + if quantity > item.quantity: + raise ValidationError({ + 'quantity': _('Allocated quantity exceeds available stock quantity') + }) + # Split the allocated stock if there are more available than allocated - if item.quantity > self.quantity: - item = item.splitStock(self.quantity, None, user, notes=notes) + if item.quantity > quantity: + item = item.splitStock(quantity, None, user, notes=notes) # For a trackable part, special consideration needed! if item.part.trackable: @@ -1835,7 +1865,7 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel): # Install the stock item into the output self.install_into.installStockItem( - item, self.quantity, user, notes, build=self.build + item, quantity, user, notes, build=self.build ) else: @@ -1851,6 +1881,18 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel): deltas={'buildorder': self.build.pk, 'quantity': float(item.quantity)}, ) + # Increase the "consumed" count for the associated BuildLine + self.build_line.consumed += quantity + self.build_line.save() + + # Decrease the allocated quantity + self.quantity = max(0, self.quantity - quantity) + + if self.quantity <= 0: + self.delete() + else: + self.save() + build_line = models.ForeignKey( BuildLine, on_delete=models.CASCADE, null=True, related_name='allocations' ) diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index 46ef73c4e9..172fdd6f2e 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -171,6 +171,9 @@ class BuildSerializer( def __init__(self, *args, **kwargs): """Determine if extra serializer fields are required.""" part_detail = kwargs.pop('part_detail', True) + user_detail = kwargs.pop('user_detail', True) + project_code_detail = kwargs.pop('project_code_detail', True) + kwargs.pop('create', False) super().__init__(*args, **kwargs) @@ -181,6 +184,15 @@ class BuildSerializer( if not part_detail: self.fields.pop('part_detail', None) + if not user_detail: + self.fields.pop('issued_by_detail', None) + self.fields.pop('responsible_detail', None) + + if not project_code_detail: + self.fields.pop('project_code', None) + self.fields.pop('project_code_label', None) + self.fields.pop('project_code_detail', None) + def validate_reference(self, reference): """Custom validation for the Build reference field.""" # Ensure the reference matches the required pattern @@ -1000,7 +1012,7 @@ class BuildAllocationItemSerializer(serializers.Serializer): class BuildAllocationSerializer(serializers.Serializer): - """DRF serializer for allocation stock items against a build order.""" + """Serializer for allocating stock items against a build order.""" class Meta: """Serializer metaclass.""" @@ -1302,6 +1314,8 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali 'build', 'bom_item', 'quantity', + 'consumed', + 'allocations', 'part', # Build detail fields 'build_reference', @@ -1353,6 +1367,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali if not part_detail: self.fields.pop('part_detail', None) + self.fields.pop('part_category_name', None) if not build_detail: self.fields.pop('build_detail', None) @@ -1379,7 +1394,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali read_only=True, ) - allocations = BuildItemSerializer(many=True, read_only=True) + allocations = BuildItemSerializer(many=True, read_only=True, build_detail=False) # BOM item info fields reference = serializers.CharField( @@ -1405,6 +1420,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali ) quantity = serializers.FloatField(label=_('Quantity')) + consumed = serializers.FloatField(label=_('Consumed')) bom_item = serializers.PrimaryKeyRelatedField(label=_('BOM Item'), read_only=True) @@ -1437,17 +1453,23 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali read_only=True, pricing=False, ) + build_detail = BuildSerializer( label=_('Build'), source='build', - part_detail=False, many=False, read_only=True, allow_null=True, + part_detail=False, + user_detail=False, + project_code_detail=False, ) # Annotated (calculated) fields - allocated = serializers.FloatField(label=_('Allocated Stock'), read_only=True) + + # Total quantity of allocated stock + allocated = serializers.FloatField(label=_('Allocated'), read_only=True) + on_order = serializers.FloatField(label=_('On Order'), read_only=True) in_production = serializers.FloatField(label=_('In Production'), read_only=True) scheduled_to_build = serializers.FloatField( @@ -1498,6 +1520,9 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali 'allocations__stock_item', 'allocations__stock_item__part', 'allocations__stock_item__location', + 'bom_item', + 'bom_item__part', + 'bom_item__sub_part', 'bom_item__sub_part__stock_items', 'bom_item__sub_part__stock_items__allocations', 'bom_item__sub_part__stock_items__sales_order_allocations', @@ -1518,7 +1543,6 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali 'build__destination', 'build__take_from', 'build__completed_by', - 'build__issued_by', 'build__sales_order', 'build__parent', 'build__notes', @@ -1692,3 +1716,177 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali ) return queryset + + +class BuildConsumeAllocationSerializer(serializers.Serializer): + """Serializer for an individual BuildItem to be consumed against a BuildOrder.""" + + class Meta: + """Serializer metaclass.""" + + fields = ['build_item', 'quantity'] + + build_item = serializers.PrimaryKeyRelatedField( + queryset=BuildItem.objects.all(), many=False, allow_null=False, required=True + ) + + quantity = serializers.DecimalField( + max_digits=15, decimal_places=5, min_value=Decimal(0), required=True + ) + + def validate_quantity(self, quantity): + """Perform validation on the 'quantity' field.""" + if quantity <= 0: + raise ValidationError(_('Quantity must be greater than zero')) + + return quantity + + def validate(self, data): + """Validate the serializer data.""" + data = super().validate(data) + + build_item = data['build_item'] + quantity = data['quantity'] + + if quantity > build_item.quantity: + raise ValidationError({ + 'quantity': _('Consumed quantity exceeds allocated quantity') + }) + + return data + + +class BuildConsumeLineItemSerializer(serializers.Serializer): + """Serializer for an individual BuildLine to be consumed against a BuildOrder.""" + + class Meta: + """Serializer metaclass.""" + + fields = ['build_line'] + + build_line = serializers.PrimaryKeyRelatedField( + queryset=BuildLine.objects.all(), many=False, allow_null=False, required=True + ) + + +class BuildConsumeSerializer(serializers.Serializer): + """Serializer for consuming allocations against a BuildOrder. + + - Consumes allocated stock items, increasing the 'consumed' field for each BuildLine. + - Stock can be consumed by passing either a list of BuildItem objects, or a list of BuildLine objects. + """ + + class Meta: + """Serializer metaclass.""" + + fields = ['items', 'lines', 'notes'] + + items = BuildConsumeAllocationSerializer(many=True, required=False) + + lines = BuildConsumeLineItemSerializer(many=True, required=False) + + notes = serializers.CharField( + label=_('Notes'), + help_text=_('Optional notes for the stock consumption'), + required=False, + allow_blank=True, + ) + + def validate_items(self, items): + """Validate the BuildItem list passed to the serializer.""" + build_order = self.context['build'] + + seen = set() + + for item in items: + build_item = item['build_item'] + + # BuildItem must point to the correct build order + if build_item.build != build_order: + raise ValidationError( + _('Build item must point to the correct build order') + ) + + # Prevent duplicate item allocation + if build_item.pk in seen: + raise ValidationError(_('Duplicate build item allocation')) + + seen.add(build_item.pk) + + return items + + def validate_lines(self, lines): + """Validate the BuildLine list passed to the serializer.""" + build_order = self.context['build'] + + seen = set() + + for line in lines: + build_line = line['build_line'] + + # BuildLine must point to the correct build order + if build_line.build != build_order: + raise ValidationError( + _('Build line must point to the correct build order') + ) + + # Prevent duplicate line allocation + if build_line.pk in seen: + raise ValidationError(_('Duplicate build line allocation')) + + seen.add(build_line.pk) + + return lines + + def validate(self, data): + """Validate the serializer data.""" + items = data.get('items', []) + lines = data.get('lines', []) + + if len(items) == 0 and len(lines) == 0: + raise ValidationError(_('At least one item or line must be provided')) + + return data + + def save(self): + """Perform the stock consumption step.""" + data = self.validated_data + request = self.context.get('request') + notes = data.get('notes', '') + + items = data.get('items', []) + lines = data.get('lines', []) + + with transaction.atomic(): + # Process the provided BuildItem objects + for item in items: + build_item = item['build_item'] + quantity = item['quantity'] + + if build_item.install_into: + # If the build item is tracked into an output, we do not consume now + # Instead, it gets consumed when the output is completed + continue + + build_item.complete_allocation( + quantity=quantity, + notes=notes, + user=request.user if request else None, + ) + + # Process the provided BuildLine objects + for line in lines: + build_line = line['build_line'] + + # In this case, perform full consumption of all allocated stock + for item in build_line.allocations.all(): + # If the build item is tracked into an output, we do not consume now + # Instead, it gets consumed when the output is completed + if item.install_into: + continue + + item.complete_allocation( + quantity=item.quantity, + notes=notes, + user=request.user if request else None, + ) diff --git a/src/backend/InvenTree/build/test_api.py b/src/backend/InvenTree/build/test_api.py index 41bec87577..511ba7a3de 100644 --- a/src/backend/InvenTree/build/test_api.py +++ b/src/backend/InvenTree/build/test_api.py @@ -990,11 +990,12 @@ class BuildOverallocationTest(BuildAPITest): self.assertEqual(si.quantity, oq) # Accept overallocated stock + # TODO: (2025-07-16) Look into optimizing this API query to reduce DB hits self.post( self.url, {'accept_overallocated': 'accept'}, expected_code=201, - max_query_count=375, + max_query_count=400, ) self.build.refresh_from_db() @@ -1009,11 +1010,12 @@ class BuildOverallocationTest(BuildAPITest): def test_overallocated_can_trim(self): """Test build order will trim/de-allocate overallocated stock when requested.""" + # TODO: (2025-07-16) Look into optimizing this API query to reduce DB hits self.post( self.url, {'accept_overallocated': 'trim'}, expected_code=201, - max_query_count=400, + max_query_count=450, ) # Note: Large number of queries is due to pricing recalculation for each stock item @@ -1323,3 +1325,177 @@ class BuildLineTests(BuildAPITest): self.assertGreater(n_f, 0) self.assertEqual(n_t + n_f, BuildLine.objects.count()) + + def test_filter_consumed(self): + """Filter for the 'consumed' status.""" + # Create a new build order + assembly = Part.objects.create( + name='Test Assembly', + description='Test Assembly Description', + assembly=True, + trackable=True, + ) + + for idx in range(3): + component = Part.objects.create( + name=f'Test Component {idx}', + description=f'Test Component Description {idx}', + trackable=True, + component=True, + ) + + # Create a BOM item for the assembly + BomItem.objects.create(part=assembly, sub_part=component, quantity=10) + + build = Build.objects.create( + part=assembly, reference='BO-12348', quantity=10, title='Test Build' + ) + + lines = list(build.build_lines.all()) + self.assertEqual(len(lines), 3) + + for line in lines: + self.assertEqual(line.quantity, 100) + self.assertEqual(line.consumed, 0) + + # Artificially "consume" some of the build lines + lines[0].consumed = 1 + lines[0].save() + + lines[1].consumed = 50 + lines[1].save() + + lines[2].consumed = 100 + lines[2].save() + + url = reverse('api-build-line-list') + + response = self.get(url, {'build': build.pk, 'consumed': True}) + + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]['pk'], lines[2].pk) + self.assertEqual(response.data[0]['consumed'], 100) + self.assertEqual(response.data[0]['quantity'], 100) + + response = self.get(url, {'build': build.pk, 'consumed': False}) + + self.assertEqual(len(response.data), 2) + self.assertEqual(response.data[0]['pk'], lines[0].pk) + self.assertEqual(response.data[0]['consumed'], 1) + self.assertEqual(response.data[0]['quantity'], 100) + self.assertEqual(response.data[1]['pk'], lines[1].pk) + self.assertEqual(response.data[1]['consumed'], 50) + self.assertEqual(response.data[1]['quantity'], 100) + + # Check that the 'available' filter works correctly also when lines are partially consumed + for line in lines: + StockItem.objects.create(part=line.bom_item.sub_part, quantity=60) + + # Note: The max_query_time is bumped up here, as postgresql backend has some strange issues (only during testing) + response = self.get( + url, {'build': build.pk, 'available': True}, max_query_time=15 + ) + + # We expect 2 lines to have "available" stock + self.assertEqual(len(response.data), 2) + + # Note: The max_query_time is bumped up here, as postgresql backend has some strange issues (only during testing) + response = self.get( + url, {'build': build.pk, 'available': False}, max_query_time=15 + ) + + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]['pk'], lines[0].pk) + + +class BuildConsumeTest(BuildAPITest): + """Test consuming allocated stock.""" + + def setUp(self): + """Set up test data.""" + super().setUp() + + self.assembly = Part.objects.create( + name='Test Assembly', description='Test Assembly Description', assembly=True + ) + + self.components = [ + Part.objects.create( + name=f'Test Component {i}', + description=f'Test Component Description {i}', + component=True, + ) + for i in range(3) + ] + + self.stock_items = [ + StockItem.objects.create(part=component, quantity=1000) + for component in self.components + ] + + self.bom_items = [ + BomItem.objects.create(part=self.assembly, sub_part=component, quantity=10) + for component in self.components + ] + + self.build = Build.objects.create( + part=self.assembly, reference='BO-12349', quantity=10, title='Test Build' + ) + + def allocate_stock(self): + """Allocate stock items to the build.""" + data = { + 'items': [ + {'build_line': line.pk, 'stock_item': si.pk, 'quantity': 100} + for line, si in zip(self.build.build_lines.all(), self.stock_items) + ] + } + + self.post( + reverse('api-build-allocate', kwargs={'pk': self.build.pk}), + data, + expected_code=201, + ) + + def test_consume_lines(self): + """Test consuming against build lines.""" + self.allocate_stock() + + self.assertEqual(self.build.allocated_stock.count(), 3) + self.assertEqual(self.build.consumed_stock.count(), 0) + url = reverse('api-build-consume', kwargs={'pk': self.build.pk}) + + data = { + 'lines': [{'build_line': line.pk} for line in self.build.build_lines.all()] + } + + self.post(url, data, expected_code=201) + + self.assertEqual(self.build.allocated_stock.count(), 0) + self.assertEqual(self.build.consumed_stock.count(), 3) + + for line in self.build.build_lines.all(): + self.assertEqual(line.consumed, 100) + + def test_consume_items(self): + """Test consuming against build items.""" + self.allocate_stock() + + self.assertEqual(self.build.allocated_stock.count(), 3) + self.assertEqual(self.build.consumed_stock.count(), 0) + url = reverse('api-build-consume', kwargs={'pk': self.build.pk}) + + data = { + 'items': [ + {'build_item': item.pk, 'quantity': item.quantity} + for item in self.build.allocated_stock.all() + ] + } + + self.post(url, data, expected_code=201) + + self.assertEqual(self.build.allocated_stock.count(), 0) + self.assertEqual(self.build.consumed_stock.count(), 3) + + for line in self.build.build_lines.all(): + self.assertEqual(line.consumed, 100) diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index 496cd24ef6..37642a3787 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -1400,9 +1400,10 @@ class Part( # Match BOM item to build for bom_item in bom_items: - build_quantity = build.quantity * bom_item.quantity + build_line = build.build_lines.filter(bom_item=bom_item).first() - quantity += build_quantity + line_quantity = max(0, build_line.quantity - build_line.consumed) + quantity += line_quantity return quantity diff --git a/src/frontend/lib/components/YesNoButton.tsx b/src/frontend/lib/components/YesNoButton.tsx index ee52e2af2c..a112692c08 100644 --- a/src/frontend/lib/components/YesNoButton.tsx +++ b/src/frontend/lib/components/YesNoButton.tsx @@ -1,24 +1,31 @@ import { t } from '@lingui/core/macro'; -import { Badge, Skeleton } from '@mantine/core'; +import { Badge, type MantineColor, Skeleton } from '@mantine/core'; import { isTrue } from '../functions/Conversion'; export function PassFailButton({ value, passText, - failText + failText, + passColor, + failColor }: Readonly<{ value: any; passText?: string; failText?: string; + passColor?: MantineColor; + failColor?: MantineColor; }>) { const v = isTrue(value); const pass = passText ?? t`Pass`; const fail = failText ?? t`Fail`; + const pColor = passColor ?? 'green'; + const fColor = failColor ?? 'red'; + return ( ) { - return ; + return ( + + ); } export function YesNoUndefinedButton({ value }: Readonly<{ value?: boolean }>) { diff --git a/src/frontend/lib/enums/ApiEndpoints.tsx b/src/frontend/lib/enums/ApiEndpoints.tsx index 628b858c34..badd9e14c2 100644 --- a/src/frontend/lib/enums/ApiEndpoints.tsx +++ b/src/frontend/lib/enums/ApiEndpoints.tsx @@ -96,6 +96,7 @@ export enum ApiEndpoints { build_output_delete = 'build/:id/delete-outputs/', build_order_auto_allocate = 'build/:id/auto-allocate/', build_order_allocate = 'build/:id/allocate/', + build_order_consume = 'build/:id/consume/', build_order_deallocate = 'build/:id/unallocate/', build_line_list = 'build/line/', diff --git a/src/frontend/src/components/render/Part.tsx b/src/frontend/src/components/render/Part.tsx index a76c45502a..dc6ae39db8 100644 --- a/src/frontend/src/components/render/Part.tsx +++ b/src/frontend/src/components/render/Part.tsx @@ -60,6 +60,10 @@ export function RenderPartCategory( ): ReactNode { const { instance } = props; + if (!instance) { + return ''; + } + const suffix: ReactNode = ( @@ -670,15 +674,220 @@ export function useAllocateStockToBuildForm({ successMessage: t`Stock items allocated`, onFormSuccess: onFormSuccess, initialData: { - items: lineItems.map((item) => { - return { - build_line: item.pk, - stock_item: undefined, - quantity: Math.max(0, item.requiredQuantity - item.allocatedQuantity), - output: outputId - }; - }) + items: lineItems + .filter((item) => { + return item.requiredQuantity > item.allocatedQuantity + item.consumed; + }) + .map((item) => { + return { + build_line: item.pk, + stock_item: undefined, + quantity: Math.max( + 0, + item.requiredQuantity - item.allocatedQuantity - item.consumed + ), + output: outputId + }; + }) }, size: '80%' }); } + +function BuildConsumeItemRow({ + props, + record +}: { + props: TableFieldRowProps; + record: any; +}) { + return ( + + + + + + + + + {record.location_detail && ( + + )} + + {record.quantity} + + { + props.changeFn(props.idx, 'quantity', value); + } + }} + error={props.rowErrors?.quantity?.message} + /> + + + props.removeFn(props.idx)} /> + + + ); +} + +/** + * Dynamic form for consuming stock against multiple BuildItem records + */ +export function useConsumeBuildItemsForm({ + buildId, + allocatedItems, + onFormSuccess +}: { + buildId: number; + allocatedItems: any[]; + onFormSuccess: (response: any) => void; +}) { + const consumeFields: ApiFormFieldSet = useMemo(() => { + return { + items: { + field_type: 'table', + value: [], + headers: [ + { title: t`Part` }, + { title: t`Stock Item` }, + { title: t`Location` }, + { title: t`Allocated` }, + { title: t`Quantity` } + ], + modelRenderer: (row: TableFieldRowProps) => { + const record = allocatedItems.find( + (item) => item.pk == row.item.build_item + ); + + return ( + + ); + } + }, + notes: {} + }; + }, [allocatedItems]); + + return useCreateApiFormModal({ + url: ApiEndpoints.build_order_consume, + pk: buildId, + title: t`Consume Stock`, + successMessage: t`Stock items consumed`, + onFormSuccess: onFormSuccess, + size: '80%', + fields: consumeFields, + initialData: { + items: allocatedItems.map((item) => { + return { + build_item: item.pk, + quantity: item.quantity + }; + }) + } + }); +} + +function BuildConsumeLineRow({ + props, + record +}: { + props: TableFieldRowProps; + record: any; +}) { + const allocated: number = record.allocatedQuantity ?? record.allocated; + const required: number = record.requiredQuantity ?? record.required; + const remaining: number = Math.max(0, required - record.consumed); + + return ( + + + + + + {remaining <= 0 ? ( + + + + {t`Fully consumed`} + + + ) : ( + + )} + + + + + + props.removeFn(props.idx)} /> + + + ); +} + +/** + * Dynamic form for consuming stock against multiple BuildLine records + */ +export function useConsumeBuildLinesForm({ + buildId, + buildLines, + onFormSuccess +}: { + buildId: number; + buildLines: any[]; + onFormSuccess: (response: any) => void; +}) { + const filteredLines = useMemo(() => { + return buildLines.filter((line) => !line.part_detail?.trackable); + }, [buildLines]); + + const consumeFields: ApiFormFieldSet = useMemo(() => { + return { + lines: { + field_type: 'table', + value: [], + headers: [ + { title: t`Part` }, + { title: t`Allocated` }, + { title: t`Consumed` } + ], + modelRenderer: (row: TableFieldRowProps) => { + const record = filteredLines.find( + (item) => item.pk == row.item.build_line + ); + + return ( + + ); + } + }, + notes: {} + }; + }, [filteredLines]); + + return useCreateApiFormModal({ + url: ApiEndpoints.build_order_consume, + pk: buildId, + title: t`Consume Stock`, + successMessage: t`Stock items consumed`, + onFormSuccess: onFormSuccess, + fields: consumeFields, + initialData: { + lines: filteredLines.map((item) => { + return { + build_line: item.pk + }; + }) + } + }); +} diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx index b0ab6c25bc..3df3dd8543 100644 --- a/src/frontend/src/forms/StockForms.tsx +++ b/src/frontend/src/forms/StockForms.tsx @@ -46,6 +46,7 @@ import { import { Thumbnail } from '../components/images/Thumbnail'; import { StylishText } from '../components/items/StylishText'; import { StatusRenderer } from '../components/render/StatusRenderer'; +import { RenderStockLocation } from '../components/render/Stock'; import { InvenTreeIcon } from '../functions/icons'; import { useApiFormModal, @@ -576,7 +577,7 @@ function StockOperationsRow({ - {record.location ? record.location_detail?.pathstring : '-'} + {record.batch ? record.batch : '-'} diff --git a/src/frontend/src/tables/ColumnRenderers.tsx b/src/frontend/src/tables/ColumnRenderers.tsx index 155a2a8097..e840731e06 100644 --- a/src/frontend/src/tables/ColumnRenderers.tsx +++ b/src/frontend/src/tables/ColumnRenderers.tsx @@ -2,7 +2,7 @@ * Common rendering functions for table column data. */ import { t } from '@lingui/core/macro'; -import { Anchor, Group, Skeleton, Text, Tooltip } from '@mantine/core'; +import { Anchor, Center, Group, Skeleton, Text, Tooltip } from '@mantine/core'; import { IconBell, IconExclamationCircle, @@ -210,7 +210,9 @@ export function BooleanColumn(props: TableColumn): TableColumn { sortable: true, switchable: true, render: (record: any) => ( - +
+ +
), ...props }; diff --git a/src/frontend/src/tables/build/BuildAllocatedStockTable.tsx b/src/frontend/src/tables/build/BuildAllocatedStockTable.tsx index 9ce710ce25..e8ee86db27 100644 --- a/src/frontend/src/tables/build/BuildAllocatedStockTable.tsx +++ b/src/frontend/src/tables/build/BuildAllocatedStockTable.tsx @@ -10,8 +10,11 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; import { apiUrl } from '@lib/functions/Api'; +import { ActionButton } from '@lib/index'; import type { TableFilter } from '@lib/types/Filters'; import type { TableColumn } from '@lib/types/Tables'; +import { IconCircleDashedCheck } from '@tabler/icons-react'; +import { useConsumeBuildItemsForm } from '../../forms/BuildForms'; import type { StockOperationProps } from '../../forms/StockForms'; import { useDeleteApiFormModal, @@ -160,10 +163,10 @@ export default function BuildAllocatedStockTable({ ]; }, []); - const [selectedItem, setSelectedItem] = useState(0); + const [selectedItemId, setSelectedItemId] = useState(0); const editItem = useEditApiFormModal({ - pk: selectedItem, + pk: selectedItemId, url: ApiEndpoints.build_item_list, title: t`Edit Stock Allocation`, fields: { @@ -176,12 +179,23 @@ export default function BuildAllocatedStockTable({ }); const deleteItem = useDeleteApiFormModal({ - pk: selectedItem, + pk: selectedItemId, url: ApiEndpoints.build_item_list, title: t`Delete Stock Allocation`, table: table }); + const [selectedItems, setSelectedItems] = useState([]); + + const consumeStock = useConsumeBuildItemsForm({ + buildId: buildId ?? 0, + allocatedItems: selectedItems, + onFormSuccess: () => { + table.clearSelectedRecords(); + table.refreshTable(); + } + }); + const stockOperationProps: StockOperationProps = useMemo(() => { // Extract stock items from the selected records // Note that the table is actually a list of BuildItem instances, @@ -216,17 +230,28 @@ export default function BuildAllocatedStockTable({ const rowActions = useCallback( (record: any): RowAction[] => { return [ + { + color: 'green', + icon: , + title: t`Consume`, + tooltip: t`Consume Stock`, + hidden: !user.hasChangeRole(UserRoles.build), + onClick: () => { + setSelectedItems([record]); + consumeStock.open(); + } + }, RowEditAction({ hidden: !user.hasChangeRole(UserRoles.build), onClick: () => { - setSelectedItem(record.pk); + setSelectedItemId(record.pk); editItem.open(); } }), RowDeleteAction({ hidden: !user.hasDeleteRole(UserRoles.build), onClick: () => { - setSelectedItem(record.pk); + setSelectedItemId(record.pk); deleteItem.open(); } }) @@ -236,13 +261,28 @@ export default function BuildAllocatedStockTable({ ); const tableActions = useMemo(() => { - return [stockAdjustActions.dropdown]; - }, [stockAdjustActions.dropdown]); + return [ + stockAdjustActions.dropdown, + } + tooltip={t`Consume Stock`} + hidden={!user.hasChangeRole(UserRoles.build)} + disabled={table.selectedRecords.length == 0} + color='green' + onClick={() => { + setSelectedItems(table.selectedRecords); + consumeStock.open(); + }} + /> + ]; + }, [user, table.selectedRecords, stockAdjustActions.dropdown]); return ( <> {editItem.modal} {deleteItem.modal} + {consumeStock.modal} {stockAdjustActions.modals.map((modal) => modal.modal)} ) { const user = useUserState(); const navigate = useNavigate(); + const table = useTable('buildline-subtable'); const tableColumns: any[] = useMemo(() => { return [ @@ -96,59 +98,52 @@ export function BuildLineSubTable({ }, LocationColumn({ accessor: 'location_detail' - }), - { - accessor: '---actions---', - title: ' ', - width: 50, - render: (record: any) => { - return ( - { - onEditAllocation?.(record.pk); - } - }), - RowDeleteAction({ - hidden: - !onDeleteAllocation || !user.hasDeleteRole(UserRoles.build), - onClick: () => { - onDeleteAllocation?.(record.pk); - } - }) - ]} - /> - ); - } - } + }) ]; - }, [user, onEditAllocation, onDeleteAllocation]); + }, []); + + const rowActions = useCallback( + (record: any): RowAction[] => { + return [ + RowViewAction({ + title: t`View Stock Item`, + modelType: ModelType.stockitem, + modelId: record.stock_item, + navigate: navigate + }), + RowEditAction({ + hidden: !onEditAllocation || !user.hasChangeRole(UserRoles.build), + onClick: () => { + onEditAllocation?.(record.pk); + } + }), + RowDeleteAction({ + hidden: !onDeleteAllocation || !user.hasDeleteRole(UserRoles.build), + onClick: () => { + onDeleteAllocation?.(record.pk); + } + }) + ]; + }, + [user, onEditAllocation, onDeleteAllocation] + ); return ( - - - - + + ); } @@ -186,7 +181,12 @@ export default function BuildLineTable({ { name: 'allocated', label: t`Allocated`, - description: t`Show allocated lines` + description: t`Show fully allocated lines` + }, + { + name: 'consumed', + label: t`Consumed`, + description: t`Show fully consumed lines` }, { name: 'available', @@ -471,13 +471,56 @@ export default function BuildLineTable({ switchable: false, sortable: true, hidden: !isActive, + render: (record: any) => { + if (record?.bom_item_detail?.consumable) { + return ( + {t`Consumable item`} + ); + } + + let required = Math.max(0, record.quantity - record.consumed); + + if (output?.pk) { + // If an output is specified, we show the allocated quantity for that output + required = record.bom_item_detail?.quantity; + } + + if (required <= 0) { + return ( + + + + {record.consumed >= record.quantity + ? t`Fully consumed` + : t`Fully allocated`} + + + ); + } + + return ( + + ); + } + }, + { + accessor: 'consumed', + sortable: true, + hidden: !!output?.pk, render: (record: any) => { return record?.bom_item_detail?.consumable ? ( {t`Consumable item`} ) : ( ); @@ -544,6 +587,7 @@ export default function BuildLineTable({ buildId: build.pk, lineItems: selectedRows, onFormSuccess: () => { + table.clearSelectedRecords(); table.refreshTable(); } }); @@ -574,7 +618,10 @@ export default function BuildLineTable({ ), successMessage: t`Stock has been deallocated`, - table: table + onFormSuccess: () => { + table.clearSelectedRecords(); + table.refreshTable(); + } }); const [selectedAllocation, setSelectedAllocation] = useState(0); @@ -605,6 +652,15 @@ export default function BuildLineTable({ parts: partsToOrder }); + const consumeLines = useConsumeBuildLinesForm({ + buildId: build.pk, + buildLines: selectedRows, + onFormSuccess: () => { + table.clearSelectedRecords(); + table.refreshTable(); + } + }); + const rowActions = useCallback( (record: any): RowAction[] => { const part = record.part_detail ?? {}; @@ -613,11 +669,24 @@ export default function BuildLineTable({ const hasOutput = !!output?.pk; + const required = Math.max( + 0, + record.quantity - record.consumed - record.allocated + ); + + // Can consume + const canConsume = + in_production && + !consumable && + record.allocated > 0 && + user.hasChangeRole(UserRoles.build); + // Can allocate const canAllocate = in_production && !consumable && user.hasChangeRole(UserRoles.build) && + required > 0 && record.trackable == hasOutput; // Can de-allocate @@ -647,6 +716,16 @@ export default function BuildLineTable({ allocateStock.open(); } }, + { + icon: , + title: t`Consume Stock`, + color: 'green', + hidden: !canConsume || hasOutput, + onClick: () => { + setSelectedRows([record]); + consumeLines.open(); + } + }, { icon: , title: t`Deallocate Stock`, @@ -758,6 +837,18 @@ export default function BuildLineTable({ setSelectedLine(null); deallocateStock.open(); }} + />, + } + tooltip={t`Consume Stock`} + hidden={!visible || hasOutput} + disabled={!table.hasSelectedRecords} + color='green' + onClick={() => { + setSelectedRows(table.selectedRecords); + consumeLines.open(); + }} /> ]; }, [ @@ -843,6 +934,7 @@ export default function BuildLineTable({ {deallocateStock.modal} {editAllocation.modal} {deleteAllocation.modal} + {consumeLines.modal} {orderPartsWizard.wizard} + {t`Build Output Stock Allocation`} - - {output?.serial && ( - - {t`Serial Number`}: {output.serial} - - )} - {output?.batch && ( - - {t`Batch Code`}: {output.batch} - - )} + + + + {output?.serial && ( + + {t`Serial Number`}: {output.serial} + + )} + {output?.batch && ( + + {t`Batch Code`}: {output.batch} + + )} + + } diff --git a/src/frontend/src/tables/part/PartBuildAllocationsTable.tsx b/src/frontend/src/tables/part/PartBuildAllocationsTable.tsx index 5231e3d66f..2ca20d56de 100644 --- a/src/frontend/src/tables/part/PartBuildAllocationsTable.tsx +++ b/src/frontend/src/tables/part/PartBuildAllocationsTable.tsx @@ -12,6 +12,7 @@ import { UserRoles } from '@lib/enums/Roles'; import { apiUrl } from '@lib/functions/Api'; import type { TableFilter } from '@lib/types/Filters'; import type { TableColumn } from '@lib/types/Tables'; +import { IconCircleCheck } from '@tabler/icons-react'; import { useTable } from '../../hooks/UseTable'; import { useUserState } from '../../states/UserState'; import { @@ -92,13 +93,30 @@ export default function PartBuildAllocationsTable({ sortable: true, switchable: false, title: t`Required Stock`, - render: (record: any) => ( - - ) + render: (record: any) => { + const required = Math.max(0, record.quantity - record.consumed); + + if (required <= 0) { + return ( + + + + {record.consumed >= record.quantity + ? t`Fully consumed` + : t`Fully allocated`} + + + ); + } + + return ( + + ); + } } ]; }, [table.isRowExpanded]); @@ -142,11 +160,13 @@ export default function PartBuildAllocationsTable({ tableState={table} columns={tableColumns} props={{ - minHeight: 200, + minHeight: 300, params: { part: partId, consumable: false, - part_detail: true, + part_detail: false, + bom_item_detail: false, + project_code_detail: true, assembly_detail: true, build_detail: true, order_outstanding: true diff --git a/src/frontend/tests/pages/pui_build.spec.ts b/src/frontend/tests/pages/pui_build.spec.ts index eb933bb96e..9fd3d41c27 100644 --- a/src/frontend/tests/pages/pui_build.spec.ts +++ b/src/frontend/tests/pages/pui_build.spec.ts @@ -350,6 +350,59 @@ test('Build Order - Allocation', async ({ browser }) => { .waitFor(); }); +// Test partial stock consumption against build order +test('Build Order - Consume Stock', async ({ browser }) => { + const page = await doCachedLogin(browser, { + url: 'manufacturing/build-order/24/line-items' + }); + + // Check for expected progress values + await page.getByText('2 / 2', { exact: true }).waitFor(); + await page.getByText('8 / 10', { exact: true }).waitFor(); + await page.getByText('5 / 35', { exact: true }).waitFor(); + await page.getByText('5 / 40', { exact: true }).waitFor(); + + // Open the "Allocate Stock" dialog + await page.getByRole('checkbox', { name: 'Select all records' }).click(); + await page + .getByRole('button', { name: 'action-button-allocate-stock' }) + .click(); + await page + .getByLabel('Allocate Stock') + .getByText('5 / 35', { exact: true }) + .waitFor(); + await page.getByRole('button', { name: 'Cancel' }).click(); + + // Open the "Consume Stock" dialog + await page + .getByRole('button', { name: 'action-button-consume-stock' }) + .click(); + await page.getByLabel('Consume Stock').getByText('2 / 2').waitFor(); + await page.getByLabel('Consume Stock').getByText('8 / 10').waitFor(); + await page.getByLabel('Consume Stock').getByText('5 / 35').waitFor(); + await page.getByLabel('Consume Stock').getByText('5 / 40').waitFor(); + await page + .getByRole('textbox', { name: 'text-field-notes' }) + .fill('some notes here...'); + await page.getByRole('button', { name: 'Cancel' }).click(); + + // Try with a different build order + await navigate(page, 'manufacturing/build-order/26/line-items'); + await page.getByRole('checkbox', { name: 'Select all records' }).click(); + await page + .getByRole('button', { name: 'action-button-consume-stock' }) + .click(); + + await page.getByLabel('Consume Stock').getByText('306 / 1,900').waitFor(); + await page + .getByLabel('Consume Stock') + .getByText('Fully consumed') + .first() + .waitFor(); + + await page.waitForTimeout(1000); +}); + test('Build Order - Tracked Outputs', async ({ browser }) => { const page = await doCachedLogin(browser, { url: 'manufacturing/build-order/10/incomplete-outputs' diff --git a/src/frontend/tests/pages/pui_part.spec.ts b/src/frontend/tests/pages/pui_part.spec.ts index e40da22389..02f4b43808 100644 --- a/src/frontend/tests/pages/pui_part.spec.ts +++ b/src/frontend/tests/pages/pui_part.spec.ts @@ -249,7 +249,13 @@ test('Parts - Requirements', async ({ browser }) => { await page.getByText('5 / 100').waitFor(); // Allocated to sales orders await page.getByText('10 / 125').waitFor(); // In production - await page.waitForTimeout(2500); + // Also check requirements for part with open build orders which have been partially consumed + await navigate(page, 'part/105/details'); + + await page.getByText('Required: 2').waitFor(); + await page.getByText('Available: 32').waitFor(); + await page.getByText('In Stock: 34').waitFor(); + await page.getByText('2 / 2').waitFor(); // Allocated to build orders }); test('Parts - Allocations', async ({ browser }) => { @@ -377,7 +383,6 @@ test('Parts - Pricing (Supplier)', async ({ browser }) => { // Supplier Pricing await page.getByRole('button', { name: 'Supplier Pricing' }).click(); - await page.waitForTimeout(500); await page.getByRole('button', { name: 'SKU Not sorted' }).waitFor(); // Supplier Pricing - linkjumping diff --git a/src/frontend/tests/pages/pui_stock.spec.ts b/src/frontend/tests/pages/pui_stock.spec.ts index 2385b6058a..c198e39d5b 100644 --- a/src/frontend/tests/pages/pui_stock.spec.ts +++ b/src/frontend/tests/pages/pui_stock.spec.ts @@ -323,6 +323,7 @@ test('Stock - Return Items', async ({ browser }) => { name: 'action-menu-stock-operations-return-stock' }) .click(); + await page.getByText('#128').waitFor(); await page.getByText('Merge into existing stock').waitFor(); await page.getByRole('textbox', { name: 'number-field-quantity' }).fill('0'); diff --git a/src/frontend/tests/pui_plugins.spec.ts b/src/frontend/tests/pui_plugins.spec.ts index 1a7d0ea21f..4ae0b6bf6d 100644 --- a/src/frontend/tests/pui_plugins.spec.ts +++ b/src/frontend/tests/pui_plugins.spec.ts @@ -52,8 +52,6 @@ test('Plugins - Settings', async ({ browser, request }) => { .fill(originalValue == '999' ? '1000' : '999'); await page.getByRole('button', { name: 'Submit' }).click(); - await page.waitForTimeout(500); - // Change it back await page.getByLabel('edit-setting-NUMERICAL_SETTING').click(); await page.getByLabel('number-field-value').fill(originalValue); @@ -164,8 +162,6 @@ test('Plugins - Panels', async ({ browser, request }) => { value: true }); - await page.waitForTimeout(500); - // Ensure that the SampleUI plugin is enabled await setPluginState({ request, @@ -173,8 +169,6 @@ test('Plugins - Panels', async ({ browser, request }) => { state: true }); - await page.waitForTimeout(500); - // Navigate to the "part" page await navigate(page, 'part/69/'); @@ -186,20 +180,14 @@ test('Plugins - Panels', async ({ browser, request }) => { // Check out each of the plugin panels await loadTab(page, 'Broken Panel'); - await page.waitForTimeout(500); - await page.getByText('Error occurred while loading plugin content').waitFor(); - await loadTab(page, 'Dynamic Panel'); - await page.waitForTimeout(500); - await page.getByText('Instance ID: 69'); await page .getByText('This panel has been dynamically rendered by the plugin system') .waitFor(); await loadTab(page, 'Part Panel'); - await page.waitForTimeout(500); await page.getByText('This content has been rendered by a custom plugin'); // Disable the plugin, and ensure it is no longer visible @@ -260,8 +248,6 @@ test('Plugins - Locate Item', async ({ browser, request }) => { state: true }); - await page.waitForTimeout(500); - // Navigate to the "stock item" page await navigate(page, 'stock/item/287/'); await page.waitForLoadState('networkidle'); @@ -273,7 +259,6 @@ test('Plugins - Locate Item', async ({ browser, request }) => { // Show the location await page.getByLabel('breadcrumb-1-factory').click(); - await page.waitForTimeout(500); await page.getByLabel('action-button-locate-item').click(); await page.getByRole('button', { name: 'Submit' }).click();