mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 05:05:42 +00:00 
			
		
		
		
	[UI] Part requirements (#10036)
* Tweak SalesOrderAllocations table * Refactor "include_variants" filter * Improved API filtering for "SalesOrderLineItem" endpoint * Fetch part detail for table * Fix email template - Referenced template which does not exist * Refactor the "requirements" endpoint - Include variant part requirements too * Updated starred notifications * Adjust column name * Update PartDetail - Extract information from partRequirements query first * Cache BOM items * Improve PartDetail page * Enhance isGeneratingSchema - Call inspect as late as possible * Adjust PartDetail * Improve BuildOrderAllocations table * Exclude common.newsfeedentry when exporting * Updated playwright tests * Bump API version
This commit is contained in:
		| @@ -1,12 +1,17 @@ | ||||
| """InvenTree API version information.""" | ||||
|  | ||||
| # InvenTree API version | ||||
| INVENTREE_API_VERSION = 369 | ||||
| INVENTREE_API_VERSION = 370 | ||||
|  | ||||
| """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" | ||||
|  | ||||
| INVENTREE_API_TEXT = """ | ||||
|  | ||||
| v370 -> 2025-07-17 : https://github.com/inventree/InvenTree/pull/10036 | ||||
|     - Adds optional "assembly_detail" information to BuildLine API endpoint | ||||
|     - Adds "include_variants" filter to SalesOrderLineItem API endpoint | ||||
|     - Improves the "PartRequirements" API endpoint to include variant aggregations | ||||
|  | ||||
| v369 -> 2025-07-15 : https://github.com/inventree/InvenTree/pull/10023 | ||||
|     - Adds "note", "updated", "updated_by" fields to the PartParameter API endpoints | ||||
|  | ||||
|   | ||||
| @@ -50,12 +50,19 @@ def isGeneratingSchema(): | ||||
|     if isInServerThread() or isInWorkerThread(): | ||||
|         return False | ||||
|  | ||||
|     if isRunningMigrations() or isRunningBackup() or isRebuildingData(): | ||||
|         return False | ||||
|  | ||||
|     if isImportingData(): | ||||
|         return False | ||||
|  | ||||
|     if isInTestMode(): | ||||
|         return False | ||||
|  | ||||
|     if 'schema' in sys.argv: | ||||
|         return True | ||||
|  | ||||
|     # This is a very inefficient call - so we only use it as a last resort | ||||
|     return any('drf_spectacular' in frame.filename for frame in inspect.stack()) | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -533,6 +533,7 @@ class BuildLineEndpoint: | ||||
|             params = self.request.query_params | ||||
|  | ||||
|             kwargs['bom_item_detail'] = str2bool(params.get('bom_item_detail', True)) | ||||
|             kwargs['assembly_detail'] = str2bool(params.get('assembly_detail', True)) | ||||
|             kwargs['part_detail'] = str2bool(params.get('part_detail', True)) | ||||
|             kwargs['build_detail'] = str2bool(params.get('build_detail', False)) | ||||
|             kwargs['allocations'] = str2bool(params.get('allocations', True)) | ||||
|   | ||||
| @@ -1319,6 +1319,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali | ||||
|             'part_category_name', | ||||
|             # Extra detail (related field) serializers | ||||
|             'bom_item_detail', | ||||
|             'assembly_detail', | ||||
|             'part_detail', | ||||
|             'build_detail', | ||||
|         ] | ||||
| @@ -1328,6 +1329,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali | ||||
|     def __init__(self, *args, **kwargs): | ||||
|         """Determine which extra details fields should be included.""" | ||||
|         part_detail = kwargs.pop('part_detail', True) | ||||
|         assembly_detail = kwargs.pop('assembly_detail', True) | ||||
|         bom_item_detail = kwargs.pop('bom_item_detail', True) | ||||
|         build_detail = kwargs.pop('build_detail', True) | ||||
|         allocations = kwargs.pop('allocations', True) | ||||
| @@ -1349,6 +1351,9 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali | ||||
|         if not allocations: | ||||
|             self.fields.pop('allocations', None) | ||||
|  | ||||
|         if not assembly_detail: | ||||
|             self.fields.pop('assembly_detail', None) | ||||
|  | ||||
|     # Build info fields | ||||
|     build_reference = serializers.CharField( | ||||
|         source='build.reference', label=_('Build Reference'), read_only=True | ||||
| @@ -1406,6 +1411,14 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali | ||||
|         part_detail=False, | ||||
|     ) | ||||
|  | ||||
|     assembly_detail = part_serializers.PartBriefSerializer( | ||||
|         label=_('Assembly'), | ||||
|         source='bom_item.part', | ||||
|         many=False, | ||||
|         read_only=True, | ||||
|         pricing=False, | ||||
|     ) | ||||
|  | ||||
|     part_detail = part_serializers.PartBriefSerializer( | ||||
|         label=_('Part'), | ||||
|         source='bom_item.sub_part', | ||||
|   | ||||
| @@ -894,10 +894,40 @@ class SalesOrderLineItemFilter(LineItemFilter): | ||||
|         queryset=models.SalesOrder.objects.all(), field_name='order', label=_('Order') | ||||
|     ) | ||||
|  | ||||
|     def filter_include_variants(self, queryset, name, value): | ||||
|         """Filter by whether or not to include variants of the selected part. | ||||
|  | ||||
|         Note: | ||||
|         - This filter does nothing by itself, and requires the 'part' filter to be set. | ||||
|         - Refer to the 'filter_part' method for more information. | ||||
|         """ | ||||
|         return queryset | ||||
|  | ||||
|     part = rest_filters.ModelChoiceFilter( | ||||
|         queryset=Part.objects.all(), field_name='part', label=_('Part') | ||||
|         queryset=Part.objects.all(), | ||||
|         field_name='part', | ||||
|         label=_('Part'), | ||||
|         method='filter_part', | ||||
|     ) | ||||
|  | ||||
|     @extend_schema_field(OpenApiTypes.INT) | ||||
|     def filter_part(self, queryset, name, part): | ||||
|         """Filter SalesOrderLineItem by selected 'part'. | ||||
|  | ||||
|         Note: | ||||
|         - If 'include_variants' is set to True, then all variants of the selected part will be included. | ||||
|         - Otherwise, just filter by the selected part. | ||||
|         """ | ||||
|         include_variants = str2bool(self.data.get('include_variants', False)) | ||||
|  | ||||
|         # Construct a queryset of parts to filter by | ||||
|         if include_variants: | ||||
|             parts = part.get_descendants(include_self=True) | ||||
|         else: | ||||
|             parts = Part.objects.filter(pk=part.pk) | ||||
|  | ||||
|         return queryset.filter(part__in=parts) | ||||
|  | ||||
|     allocated = rest_filters.BooleanFilter( | ||||
|         label=_('Allocated'), method='filter_allocated' | ||||
|     ) | ||||
|   | ||||
| @@ -1330,29 +1330,62 @@ class Part( | ||||
|  | ||||
|         return max(total, 0) | ||||
|  | ||||
|     def requiring_build_orders(self): | ||||
|         """Return list of outstanding build orders which require this part.""" | ||||
|     def requiring_build_orders(self, include_variants: bool = True): | ||||
|         """Return list of outstanding build orders which require this part. | ||||
|  | ||||
|         Arguments: | ||||
|             include_variants: If True, include variants of this part in the calculation | ||||
|         """ | ||||
|         # List parts that this part is required for | ||||
|  | ||||
|         if include_variants: | ||||
|             # If we are including variants, get all parts in the variant tree | ||||
|             parts = list(self.get_descendants(include_self=True)) | ||||
|         else: | ||||
|             parts = [self] | ||||
|  | ||||
|         used_in_parts = set() | ||||
|  | ||||
|         for part in parts: | ||||
|             # Get all assemblies which use this part | ||||
|             used_in_parts.update(part.get_used_in()) | ||||
|  | ||||
|         # Now, get a list of outstanding build orders which require this part | ||||
|         builds = BuildModels.Build.objects.filter( | ||||
|             part__in=self.get_used_in(), status__in=BuildStatusGroups.ACTIVE_CODES | ||||
|             part__in=list(used_in_parts), status__in=BuildStatusGroups.ACTIVE_CODES | ||||
|         ) | ||||
|  | ||||
|         return builds | ||||
|  | ||||
|     def required_build_order_quantity(self): | ||||
|         """Return the quantity of this part required for active build orders.""" | ||||
|     def required_build_order_quantity(self, include_variants: bool = True): | ||||
|         """Return the quantity of this part required for active build orders. | ||||
|  | ||||
|         Arguments: | ||||
|             include_variants: If True, include variants of this part in the calculation | ||||
|         """ | ||||
|         # List active build orders which reference this part | ||||
|         builds = self.requiring_build_orders() | ||||
|         builds = self.requiring_build_orders(include_variants=include_variants) | ||||
|  | ||||
|         quantity = 0 | ||||
|  | ||||
|         for build in builds: | ||||
|             bom_item = None | ||||
|         if include_variants: | ||||
|             matching_parts = list(self.get_descendants(include_self=True)) | ||||
|         else: | ||||
|             matching_parts = [self] | ||||
|  | ||||
|             # List the bom lines required to make the build (including inherited ones!) | ||||
|             bom_items = build.part.get_bom_items().filter(sub_part=self) | ||||
|         # Cache the BOM items that we query | ||||
|         # Keep a dict of part ID to BOM items | ||||
|         cached_bom_items: dict = {} | ||||
|  | ||||
|         for build in builds: | ||||
|             if build.part.pk not in cached_bom_items: | ||||
|                 # Get the BOM items for this part | ||||
|                 bom_items = build.part.get_bom_items().filter( | ||||
|                     sub_part__in=matching_parts | ||||
|                 ) | ||||
|                 cached_bom_items[build.part.pk] = bom_items | ||||
|             else: | ||||
|                 bom_items = cached_bom_items[build.part.pk] | ||||
|  | ||||
|             # Match BOM item to build | ||||
|             for bom_item in bom_items: | ||||
| @@ -1362,13 +1395,22 @@ class Part( | ||||
|  | ||||
|         return quantity | ||||
|  | ||||
|     def requiring_sales_orders(self): | ||||
|         """Return a list of sales orders which require this part.""" | ||||
|     def requiring_sales_orders(self, include_variants: bool = True): | ||||
|         """Return a list of sales orders which require this part. | ||||
|  | ||||
|         Arguments: | ||||
|             include_variants: If True, include variants of this part in the calculation | ||||
|         """ | ||||
|         orders = set() | ||||
|  | ||||
|         if include_variants: | ||||
|             parts = list(self.get_descendants(include_self=True)) | ||||
|         else: | ||||
|             parts = [self] | ||||
|  | ||||
|         # Get a list of line items for open orders which match this part | ||||
|         open_lines = OrderModels.SalesOrderLineItem.objects.filter( | ||||
|             order__status__in=SalesOrderStatusGroups.OPEN, part=self | ||||
|             order__status__in=SalesOrderStatusGroups.OPEN, part__in=parts | ||||
|         ) | ||||
|  | ||||
|         for line in open_lines: | ||||
| @@ -1376,11 +1418,20 @@ class Part( | ||||
|  | ||||
|         return orders | ||||
|  | ||||
|     def required_sales_order_quantity(self): | ||||
|         """Return the quantity of this part required for active sales orders.""" | ||||
|     def required_sales_order_quantity(self, include_variants: bool = True): | ||||
|         """Return the quantity of this part required for active sales orders. | ||||
|  | ||||
|         Arguments: | ||||
|             include_variants: If True, include variants of this part in the calculation | ||||
|         """ | ||||
|         if include_variants: | ||||
|             parts = list(self.get_descendants(include_self=True)) | ||||
|         else: | ||||
|             parts = [self] | ||||
|  | ||||
|         # Get a list of line items for open orders which match this part | ||||
|         open_lines = OrderModels.SalesOrderLineItem.objects.filter( | ||||
|             order__status__in=SalesOrderStatusGroups.OPEN, part=self | ||||
|             order__status__in=SalesOrderStatusGroups.OPEN, part__in=parts | ||||
|         ) | ||||
|  | ||||
|         quantity = 0 | ||||
| @@ -1392,11 +1443,11 @@ class Part( | ||||
|  | ||||
|         return quantity | ||||
|  | ||||
|     def required_order_quantity(self): | ||||
|     def required_order_quantity(self, include_variants: bool = True): | ||||
|         """Return total required to fulfil orders.""" | ||||
|         return ( | ||||
|             self.required_build_order_quantity() + self.required_sales_order_quantity() | ||||
|         ) | ||||
|         return self.required_build_order_quantity( | ||||
|             include_variants=include_variants | ||||
|         ) + self.required_sales_order_quantity(include_variants=include_variants) | ||||
|  | ||||
|     @property | ||||
|     def quantity_to_order(self): | ||||
| @@ -1626,13 +1677,25 @@ class Part( | ||||
|         return self.builds.filter(status__in=BuildStatusGroups.ACTIVE_CODES) | ||||
|  | ||||
|     @property | ||||
|     def quantity_being_built(self): | ||||
|     def quantity_being_built(self, include_variants: bool = True): | ||||
|         """Return the current number of parts currently being built. | ||||
|  | ||||
|         Arguments: | ||||
|             include_variants: If True, include variants of this part in the calculation | ||||
|  | ||||
|         Note: This is the total quantity of Build orders, *not* the number of build outputs. | ||||
|               In this fashion, it is the "projected" quantity of builds | ||||
|         """ | ||||
|         builds = self.active_builds | ||||
|         builds = BuildModels.Build.objects.filter( | ||||
|             status__in=BuildStatusGroups.ACTIVE_CODES | ||||
|         ) | ||||
|  | ||||
|         if include_variants: | ||||
|             # If we are including variants, get all parts in the variant tree | ||||
|             builds = builds.filter(part__in=self.get_descendants(include_self=True)) | ||||
|         else: | ||||
|             # Only look at this part | ||||
|             builds = builds.filter(part=self) | ||||
|  | ||||
|         quantity = 0 | ||||
|  | ||||
| @@ -1643,17 +1706,27 @@ class Part( | ||||
|         return quantity | ||||
|  | ||||
|     @property | ||||
|     def quantity_in_production(self): | ||||
|     def quantity_in_production(self, include_variants: bool = True): | ||||
|         """Quantity of this part currently actively in production. | ||||
|  | ||||
|         Arguments: | ||||
|             include_variants: If True, include variants of this part in the calculation | ||||
|  | ||||
|         Note: This may return a different value to `quantity_being_built` | ||||
|         """ | ||||
|         quantity = 0 | ||||
|  | ||||
|         items = self.stock_items.filter( | ||||
|         items = StockModels.StockItem.objects.filter( | ||||
|             is_building=True, build__status__in=BuildStatusGroups.ACTIVE_CODES | ||||
|         ) | ||||
|  | ||||
|         if include_variants: | ||||
|             # If we are including variants, get all parts in the variant tree | ||||
|             items = items.filter(part__in=self.get_descendants(include_self=True)) | ||||
|         else: | ||||
|             # Only look at this part | ||||
|             items = items.filter(part=self) | ||||
|  | ||||
|         for item in items: | ||||
|             # The remaining items in the build | ||||
|             quantity += item.quantity | ||||
| @@ -1823,7 +1896,7 @@ class Part( | ||||
|             self.get_bom_item_filter(include_inherited=include_inherited) | ||||
|         ) | ||||
|  | ||||
|         return queryset.prefetch_related('sub_part') | ||||
|         return queryset.prefetch_related('part', 'sub_part') | ||||
|  | ||||
|     def get_installed_part_options( | ||||
|         self, include_inherited: bool = True, include_variants: bool = True | ||||
|   | ||||
| @@ -1277,7 +1277,7 @@ class PartRequirementsSerializer(InvenTree.serializers.InvenTreeModelSerializer) | ||||
|  | ||||
|     def get_allocated_to_sales_orders(self, part) -> float: | ||||
|         """Return the allocated sales order quantity.""" | ||||
|         return part.sales_order_allocation_count(pending=True) | ||||
|         return part.sales_order_allocation_count(include_variants=True, pending=True) | ||||
|  | ||||
|  | ||||
| class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer): | ||||
|   | ||||
| @@ -25,9 +25,9 @@ | ||||
|         <a href='{{ line.link }}'>{{ line.part.full_name }}</a>{% if line.part.description %} - <em>{{ line.part.description }}</em>{% endif %} | ||||
|     </td> | ||||
|     <td style="text-align: center;"> | ||||
|         {% decimal line.required %} {% include "part/part_units.html" with part=line.part %} | ||||
|         {% decimal line.required %} | ||||
|     </td> | ||||
|     <td style="text-align: center;">{% decimal line.available %}  {% include "part/part_units.html" with part=line.part %}</td> | ||||
|     <td style="text-align: center;">{% decimal line.available %}</td> | ||||
| </tr> | ||||
|  | ||||
| {% endfor %} | ||||
|   | ||||
		Reference in New Issue
	
	Block a user