mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 04:35:42 +00:00 
			
		
		
		
	Build order consume (#8191)
* Adds "consumed" field to BuildLine model * Expose new field to serializer * Add "consumed" column to BuildLineTable * Boolean column tweaks * Increase consumed count when completing allocation * Add comment * Update migration * Add serializer for consuming build items * Improve build-line sub-table * Refactor BuildItem.complete_allocation method - Allow optional quantity to be specified - Adjust the allocated quantity when consuming * Perform consumption * Add "BuildConsume" API endpoint * Implement frontend form * Fixes for serializer * Enhance front-end form * Fix rendering of BuildLineTable * Further improve rendering * Bump API version * Update API description * Add option to consume by specifying a list of BuildLine objects * Add form to consume stock via BuildLine reference * Fix api_version * Fix backup colors * Fix typo * Fix migrations * Fix build forms * Forms fixes * Fix formatting * Fixes for BuildLineTable * Account for consumed stock in requirements calculation * Reduce API requirements for BuildLineTable * Docs updates * Updated playwright testing * Update src/frontend/src/forms/BuildForms.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/frontend/src/tables/build/BuildLineTable.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add unit test for filters * Add functional tests * Tweak query count * Increase max query time for testing * adjust unit test again * Prevent consumption of "tracked" items * Adjust playwright tests * Fix table * Fix rendering --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
		| @@ -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 | ||||
|   | ||||
| @@ -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 = [ | ||||
|         '<int:pk>/', | ||||
|         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(), | ||||
|   | ||||
| @@ -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'), | ||||
|         ), | ||||
|     ] | ||||
| @@ -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' | ||||
|     ) | ||||
|   | ||||
| @@ -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, | ||||
|                     ) | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
|   | ||||
| @@ -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 ( | ||||
|     <Badge | ||||
|       color={v ? 'green' : 'red'} | ||||
|       color={v ? pColor : fColor} | ||||
|       variant='filled' | ||||
|       radius='lg' | ||||
|       size='sm' | ||||
| @@ -30,7 +37,14 @@ export function PassFailButton({ | ||||
| } | ||||
|  | ||||
| export function YesNoButton({ value }: Readonly<{ value: any }>) { | ||||
|   return <PassFailButton value={value} passText={t`Yes`} failText={t`No`} />; | ||||
|   return ( | ||||
|     <PassFailButton | ||||
|       value={value} | ||||
|       passText={t`Yes`} | ||||
|       failText={t`No`} | ||||
|       failColor={'orange.6'} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function YesNoUndefinedButton({ value }: Readonly<{ value?: boolean }>) { | ||||
|   | ||||
| @@ -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/', | ||||
|   | ||||
| @@ -60,6 +60,10 @@ export function RenderPartCategory( | ||||
| ): ReactNode { | ||||
|   const { instance } = props; | ||||
|  | ||||
|   if (!instance) { | ||||
|     return ''; | ||||
|   } | ||||
|  | ||||
|   const suffix: ReactNode = ( | ||||
|     <Group gap='xs'> | ||||
|       <TableHoverCard | ||||
|   | ||||
| @@ -22,6 +22,10 @@ export function RenderStockLocation( | ||||
| ): ReactNode { | ||||
|   const { instance } = props; | ||||
|  | ||||
|   if (!instance) { | ||||
|     return ''; | ||||
|   } | ||||
|  | ||||
|   const suffix: ReactNode = ( | ||||
|     <Group gap='xs'> | ||||
|       <TableHoverCard | ||||
|   | ||||
| @@ -1,7 +1,8 @@ | ||||
| import { t } from '@lingui/core/macro'; | ||||
| import { Alert, Divider, List, Stack, Table } from '@mantine/core'; | ||||
| import { Alert, Divider, Group, List, Stack, Table, Text } from '@mantine/core'; | ||||
| import { | ||||
|   IconCalendar, | ||||
|   IconCircleCheck, | ||||
|   IconInfoCircle, | ||||
|   IconLink, | ||||
|   IconList, | ||||
| @@ -25,7 +26,10 @@ import { | ||||
|   type TableFieldRowProps | ||||
| } from '../components/forms/fields/TableField'; | ||||
| import { StatusRenderer } from '../components/render/StatusRenderer'; | ||||
| import { RenderStockItem } from '../components/render/Stock'; | ||||
| import { | ||||
|   RenderStockItem, | ||||
|   RenderStockLocation | ||||
| } from '../components/render/Stock'; | ||||
| import { useCreateApiFormModal } from '../hooks/UseForm'; | ||||
| import { | ||||
|   useBatchCodeGenerator, | ||||
| @@ -542,7 +546,7 @@ function BuildAllocateLineRow({ | ||||
|       <Table.Td> | ||||
|         <ProgressBar | ||||
|           value={record.allocatedQuantity} | ||||
|           maximum={record.requiredQuantity} | ||||
|           maximum={record.requiredQuantity - record.consumed} | ||||
|           progressLabel | ||||
|         /> | ||||
|       </Table.Td> | ||||
| @@ -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 ( | ||||
|     <Table.Tr key={`table-row-${record.pk}`}> | ||||
|       <Table.Td> | ||||
|         <PartColumn part={record.part_detail} /> | ||||
|       </Table.Td> | ||||
|       <Table.Td> | ||||
|         <RenderStockItem instance={record.stock_item_detail} /> | ||||
|       </Table.Td> | ||||
|       <Table.Td> | ||||
|         {record.location_detail && ( | ||||
|           <RenderStockLocation instance={record.location_detail} /> | ||||
|         )} | ||||
|       </Table.Td> | ||||
|       <Table.Td>{record.quantity}</Table.Td> | ||||
|       <Table.Td> | ||||
|         <StandaloneField | ||||
|           fieldName='quantity' | ||||
|           fieldDefinition={{ | ||||
|             field_type: 'number', | ||||
|             required: true, | ||||
|             value: props.item.quantity, | ||||
|             onValueChange: (value: any) => { | ||||
|               props.changeFn(props.idx, 'quantity', value); | ||||
|             } | ||||
|           }} | ||||
|           error={props.rowErrors?.quantity?.message} | ||||
|         /> | ||||
|       </Table.Td> | ||||
|       <Table.Td> | ||||
|         <RemoveRowButton onClick={() => props.removeFn(props.idx)} /> | ||||
|       </Table.Td> | ||||
|     </Table.Tr> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * 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 ( | ||||
|             <BuildConsumeItemRow key={row.idx} props={row} record={record} /> | ||||
|           ); | ||||
|         } | ||||
|       }, | ||||
|       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 ( | ||||
|     <Table.Tr key={`table-row-${record.pk}`}> | ||||
|       <Table.Td> | ||||
|         <PartColumn part={record.part_detail} /> | ||||
|       </Table.Td> | ||||
|       <Table.Td> | ||||
|         {remaining <= 0 ? ( | ||||
|           <Group gap='xs'> | ||||
|             <IconCircleCheck size={16} color='green' /> | ||||
|             <Text size='sm' style={{ fontStyle: 'italic' }}> | ||||
|               {t`Fully consumed`} | ||||
|             </Text> | ||||
|           </Group> | ||||
|         ) : ( | ||||
|           <ProgressBar value={allocated} maximum={remaining} progressLabel /> | ||||
|         )} | ||||
|       </Table.Td> | ||||
|       <Table.Td> | ||||
|         <ProgressBar | ||||
|           value={record.consumed} | ||||
|           maximum={record.quantity} | ||||
|           progressLabel | ||||
|         /> | ||||
|       </Table.Td> | ||||
|       <Table.Td> | ||||
|         <RemoveRowButton onClick={() => props.removeFn(props.idx)} /> | ||||
|       </Table.Td> | ||||
|     </Table.Tr> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * 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 ( | ||||
|             <BuildConsumeLineRow key={row.idx} props={row} record={record} /> | ||||
|           ); | ||||
|         } | ||||
|       }, | ||||
|       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 | ||||
|         }; | ||||
|       }) | ||||
|     } | ||||
|   }); | ||||
| } | ||||
|   | ||||
| @@ -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({ | ||||
|           </Stack> | ||||
|         </Table.Td> | ||||
|         <Table.Td> | ||||
|           {record.location ? record.location_detail?.pathstring : '-'} | ||||
|           <RenderStockLocation instance={record.location_detail} /> | ||||
|         </Table.Td> | ||||
|         <Table.Td>{record.batch ? record.batch : '-'}</Table.Td> | ||||
|         <Table.Td> | ||||
|   | ||||
| @@ -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) => ( | ||||
|       <YesNoButton value={resolveItem(record, props.accessor ?? '')} /> | ||||
|       <Center> | ||||
|         <YesNoButton value={resolveItem(record, props.accessor ?? '')} /> | ||||
|       </Center> | ||||
|     ), | ||||
|     ...props | ||||
|   }; | ||||
|   | ||||
| @@ -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<number>(0); | ||||
|   const [selectedItemId, setSelectedItemId] = useState<number>(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<any[]>([]); | ||||
|  | ||||
|   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: <IconCircleDashedCheck />, | ||||
|           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, | ||||
|       <ActionButton | ||||
|         key='consume-stock' | ||||
|         icon={<IconCircleDashedCheck />} | ||||
|         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)} | ||||
|       <InvenTreeTable | ||||
|         tableState={table} | ||||
|   | ||||
| @@ -1,21 +1,21 @@ | ||||
| import { t } from '@lingui/core/macro'; | ||||
| import { Alert, Group, Paper, Stack, Text } from '@mantine/core'; | ||||
| import { Alert, Group, Paper, Text } from '@mantine/core'; | ||||
| import { | ||||
|   IconArrowRight, | ||||
|   IconCircleCheck, | ||||
|   IconCircleDashedCheck, | ||||
|   IconCircleMinus, | ||||
|   IconShoppingCart, | ||||
|   IconTool, | ||||
|   IconWand | ||||
| } from '@tabler/icons-react'; | ||||
| import { DataTable, type DataTableRowExpansionProps } from 'mantine-datatable'; | ||||
| import type { DataTableRowExpansionProps } from 'mantine-datatable'; | ||||
| import { useCallback, useMemo, useState } from 'react'; | ||||
| import { useNavigate } from 'react-router-dom'; | ||||
|  | ||||
| import { ActionButton } from '@lib/components/ActionButton'; | ||||
| import { ProgressBar } from '@lib/components/ProgressBar'; | ||||
| import { | ||||
|   type RowAction, | ||||
|   RowActions, | ||||
|   RowDeleteAction, | ||||
|   RowEditAction, | ||||
|   RowViewAction | ||||
| @@ -26,11 +26,12 @@ import { UserRoles } from '@lib/enums/Roles'; | ||||
| import { apiUrl } from '@lib/functions/Api'; | ||||
| import { formatDecimal } from '@lib/functions/Formatting'; | ||||
| import type { TableFilter } from '@lib/types/Filters'; | ||||
| import type { TableColumn } from '@lib/types/Tables'; | ||||
| import type { RowAction, TableColumn } from '@lib/types/Tables'; | ||||
| import OrderPartsWizard from '../../components/wizards/OrderPartsWizard'; | ||||
| import { | ||||
|   useAllocateStockToBuildForm, | ||||
|   useBuildOrderFields | ||||
|   useBuildOrderFields, | ||||
|   useConsumeBuildLinesForm | ||||
| } from '../../forms/BuildForms'; | ||||
| import { | ||||
|   useCreateApiFormModal, | ||||
| @@ -70,6 +71,7 @@ export function BuildLineSubTable({ | ||||
| }>) { | ||||
|   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 ( | ||||
|             <RowActions | ||||
|               title={t`Actions`} | ||||
|               index={record.pk} | ||||
|               actions={[ | ||||
|                 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]); | ||||
|   }, []); | ||||
|  | ||||
|   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 ( | ||||
|     <Paper p='md'> | ||||
|       <Stack gap='xs'> | ||||
|         <DataTable | ||||
|           minHeight={50} | ||||
|           withTableBorder | ||||
|           withColumnBorders | ||||
|           striped | ||||
|           pinLastColumn | ||||
|           idAccessor='pk' | ||||
|           columns={tableColumns} | ||||
|           records={lineItem.filteredAllocations ?? lineItem.allocations} | ||||
|         /> | ||||
|       </Stack> | ||||
|     <Paper p='xs'> | ||||
|       <InvenTreeTable | ||||
|         tableState={table} | ||||
|         columns={tableColumns} | ||||
|         tableData={lineItem.filteredAllocations ?? lineItem.allocations} | ||||
|         props={{ | ||||
|           minHeight: 200, | ||||
|           enableSearch: false, | ||||
|           enableRefresh: false, | ||||
|           enableColumnSwitching: false, | ||||
|           enableFilters: false, | ||||
|           rowActions: rowActions, | ||||
|           noRecordsText: '' | ||||
|         }} | ||||
|       /> | ||||
|     </Paper> | ||||
|   ); | ||||
| } | ||||
| @@ -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 ( | ||||
|               <Text | ||||
|                 size='sm' | ||||
|                 style={{ fontStyle: 'italic' }} | ||||
|               >{t`Consumable item`}</Text> | ||||
|             ); | ||||
|           } | ||||
|  | ||||
|           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 ( | ||||
|               <Group gap='xs' wrap='nowrap'> | ||||
|                 <IconCircleCheck size={16} color='green' /> | ||||
|                 <Text size='sm' style={{ fontStyle: 'italic' }}> | ||||
|                   {record.consumed >= record.quantity | ||||
|                     ? t`Fully consumed` | ||||
|                     : t`Fully allocated`} | ||||
|                 </Text> | ||||
|               </Group> | ||||
|             ); | ||||
|           } | ||||
|  | ||||
|           return ( | ||||
|             <ProgressBar | ||||
|               progressLabel={true} | ||||
|               value={record.allocatedQuantity} | ||||
|               maximum={required} | ||||
|             /> | ||||
|           ); | ||||
|         } | ||||
|       }, | ||||
|       { | ||||
|         accessor: 'consumed', | ||||
|         sortable: true, | ||||
|         hidden: !!output?.pk, | ||||
|         render: (record: any) => { | ||||
|           return record?.bom_item_detail?.consumable ? ( | ||||
|             <Text style={{ fontStyle: 'italic' }}>{t`Consumable item`}</Text> | ||||
|           ) : ( | ||||
|             <ProgressBar | ||||
|               progressLabel={true} | ||||
|               value={record.allocatedQuantity} | ||||
|               value={record.consumed} | ||||
|               maximum={record.requiredQuantity} | ||||
|             /> | ||||
|           ); | ||||
| @@ -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({ | ||||
|       </Alert> | ||||
|     ), | ||||
|     successMessage: t`Stock has been deallocated`, | ||||
|     table: table | ||||
|     onFormSuccess: () => { | ||||
|       table.clearSelectedRecords(); | ||||
|       table.refreshTable(); | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   const [selectedAllocation, setSelectedAllocation] = useState<number>(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: <IconCircleDashedCheck />, | ||||
|           title: t`Consume Stock`, | ||||
|           color: 'green', | ||||
|           hidden: !canConsume || hasOutput, | ||||
|           onClick: () => { | ||||
|             setSelectedRows([record]); | ||||
|             consumeLines.open(); | ||||
|           } | ||||
|         }, | ||||
|         { | ||||
|           icon: <IconCircleMinus />, | ||||
|           title: t`Deallocate Stock`, | ||||
| @@ -758,6 +837,18 @@ export default function BuildLineTable({ | ||||
|           setSelectedLine(null); | ||||
|           deallocateStock.open(); | ||||
|         }} | ||||
|       />, | ||||
|       <ActionButton | ||||
|         key='consume-stock' | ||||
|         icon={<IconCircleDashedCheck />} | ||||
|         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} | ||||
|       <InvenTreeTable | ||||
|         url={apiUrl(ApiEndpoints.build_line_list)} | ||||
| @@ -852,6 +944,7 @@ export default function BuildLineTable({ | ||||
|           params: { | ||||
|             ...params, | ||||
|             build: build.pk, | ||||
|             assembly_detail: false, | ||||
|             part_detail: true | ||||
|           }, | ||||
|           tableActions: tableActions, | ||||
|   | ||||
| @@ -95,20 +95,24 @@ function OutputAllocationDrawer({ | ||||
|       position='bottom' | ||||
|       size='lg' | ||||
|       title={ | ||||
|         <Group p='md' wrap='nowrap' justify='space-apart'> | ||||
|         <Group p='xs' wrap='nowrap' justify='space-apart'> | ||||
|           <StylishText size='lg'>{t`Build Output Stock Allocation`}</StylishText> | ||||
|           <Space h='lg' /> | ||||
|           <PartColumn part={build.part_detail} /> | ||||
|           {output?.serial && ( | ||||
|             <Text size='sm'> | ||||
|               {t`Serial Number`}: {output.serial} | ||||
|             </Text> | ||||
|           )} | ||||
|           {output?.batch && ( | ||||
|             <Text size='sm'> | ||||
|               {t`Batch Code`}: {output.batch} | ||||
|             </Text> | ||||
|           )} | ||||
|           <Paper withBorder p='sm'> | ||||
|             <Group gap='xs'> | ||||
|               <PartColumn part={build.part_detail} /> | ||||
|               {output?.serial && ( | ||||
|                 <Text size='sm'> | ||||
|                   {t`Serial Number`}: {output.serial} | ||||
|                 </Text> | ||||
|               )} | ||||
|               {output?.batch && ( | ||||
|                 <Text size='sm'> | ||||
|                   {t`Batch Code`}: {output.batch} | ||||
|                 </Text> | ||||
|               )} | ||||
|             </Group> | ||||
|           </Paper> | ||||
|           <Space h='lg' /> | ||||
|         </Group> | ||||
|       } | ||||
|   | ||||
| @@ -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) => ( | ||||
|           <ProgressBar | ||||
|             progressLabel | ||||
|             value={record.allocated} | ||||
|             maximum={record.quantity} | ||||
|           /> | ||||
|         ) | ||||
|         render: (record: any) => { | ||||
|           const required = Math.max(0, record.quantity - record.consumed); | ||||
|  | ||||
|           if (required <= 0) { | ||||
|             return ( | ||||
|               <Group gap='xs' wrap='nowrap'> | ||||
|                 <IconCircleCheck size={14} color='green' /> | ||||
|                 <Text size='sm' style={{ fontStyle: 'italic' }}> | ||||
|                   {record.consumed >= record.quantity | ||||
|                     ? t`Fully consumed` | ||||
|                     : t`Fully allocated`} | ||||
|                 </Text> | ||||
|               </Group> | ||||
|             ); | ||||
|           } | ||||
|  | ||||
|           return ( | ||||
|             <ProgressBar | ||||
|               progressLabel | ||||
|               value={record.allocated} | ||||
|               maximum={required} | ||||
|             /> | ||||
|           ); | ||||
|         } | ||||
|       } | ||||
|     ]; | ||||
|   }, [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 | ||||
|   | ||||
| @@ -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' | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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'); | ||||
|   | ||||
| @@ -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(); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user