mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-17 01:38:19 +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