mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55: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 | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user