diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index d937a1a4160b..2d7b716adce2 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,13 +1,17 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 291 +INVENTREE_API_VERSION = 292 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v292 - 2024-12-03 : https://github.com/inventree/InvenTree/pull/8625 + - Add "on_order" and "in_stock" annotations to SupplierPart API + - Enhanced filtering for the SupplierPart API + v291 - 2024-11-30 : https://github.com/inventree/InvenTree/pull/8596 - Allow null / empty values for plugin settings diff --git a/src/backend/InvenTree/company/api.py b/src/backend/InvenTree/company/api.py index 5765b11b85db..9ff22f6e8afe 100644 --- a/src/backend/InvenTree/company/api.py +++ b/src/backend/InvenTree/company/api.py @@ -282,6 +282,7 @@ class Meta: field_name='part__active', label=_('Internal Part is Active') ) + # Filter by 'active' status of linked supplier supplier_active = rest_filters.BooleanFilter( field_name='supplier__active', label=_('Supplier is Active') ) @@ -293,43 +294,48 @@ class Meta: lookup_expr='iexact', ) + # Filter by 'manufacturer' + manufacturer = rest_filters.ModelChoiceFilter( + label=_('Manufacturer'), + queryset=Company.objects.all(), + field_name='manufacturer_part__manufacturer', + ) -class SupplierPartList(DataExportViewMixin, ListCreateDestroyAPIView): - """API endpoint for list view of SupplierPart object. + # Filter by 'company' (either manufacturer or supplier) + company = rest_filters.ModelChoiceFilter( + label=_('Company'), queryset=Company.objects.all(), method='filter_company' + ) - - GET: Return list of SupplierPart objects - - POST: Create a new SupplierPart object - """ + def filter_company(self, queryset, name, value): + """Filter the queryset by either manufacturer or supplier.""" + return queryset.filter( + Q(manufacturer_part__manufacturer=value) | Q(supplier=value) + ).distinct() + + has_stock = rest_filters.BooleanFilter( + label=_('Has Stock'), method='filter_has_stock' + ) + + def filter_has_stock(self, queryset, name, value): + """Filter the queryset based on whether the SupplierPart has stock available.""" + if value: + return queryset.filter(in_stock__gt=0) + else: + return queryset.exclude(in_stock__gt=0) + + +class SupplierPartMixin: + """Mixin class for SupplierPart API endpoints.""" queryset = SupplierPart.objects.all().prefetch_related('tags') - filterset_class = SupplierPartFilter + serializer_class = SupplierPartSerializer def get_queryset(self, *args, **kwargs): """Return annotated queryest object for the SupplierPart list.""" queryset = super().get_queryset(*args, **kwargs) queryset = SupplierPartSerializer.annotate_queryset(queryset) - return queryset - - def filter_queryset(self, queryset): - """Custom filtering for the queryset.""" - queryset = super().filter_queryset(queryset) - - params = self.request.query_params - - # Filter by manufacturer - manufacturer = params.get('manufacturer', None) - - if manufacturer is not None: - queryset = queryset.filter(manufacturer_part__manufacturer=manufacturer) - - # Filter by EITHER manufacturer or supplier - company = params.get('company', None) - - if company is not None: - queryset = queryset.filter( - Q(manufacturer_part__manufacturer=company) | Q(supplier=company) - ).distinct() + queryset = queryset.prefetch_related('part', 'part__pricing_data') return queryset @@ -351,7 +357,17 @@ def get_serializer(self, *args, **kwargs): return self.serializer_class(*args, **kwargs) - serializer_class = SupplierPartSerializer + +class SupplierPartList( + DataExportViewMixin, SupplierPartMixin, ListCreateDestroyAPIView +): + """API endpoint for list view of SupplierPart object. + + - GET: Return list of SupplierPart objects + - POST: Create a new SupplierPart object + """ + + filterset_class = SupplierPartFilter filter_backends = SEARCH_ORDER_FILTER_ALIAS @@ -391,7 +407,7 @@ def get_serializer(self, *args, **kwargs): ] -class SupplierPartDetail(RetrieveUpdateDestroyAPI): +class SupplierPartDetail(SupplierPartMixin, RetrieveUpdateDestroyAPI): """API endpoint for detail view of SupplierPart object. - GET: Retrieve detail view @@ -399,11 +415,6 @@ class SupplierPartDetail(RetrieveUpdateDestroyAPI): - DELETE: Delete object """ - queryset = SupplierPart.objects.all() - serializer_class = SupplierPartSerializer - - read_only_fields = [] - class SupplierPriceBreakFilter(rest_filters.FilterSet): """Custom API filters for the SupplierPriceBreak list endpoint.""" diff --git a/src/backend/InvenTree/company/filters.py b/src/backend/InvenTree/company/filters.py new file mode 100644 index 000000000000..e9990c0baa8c --- /dev/null +++ b/src/backend/InvenTree/company/filters.py @@ -0,0 +1,36 @@ +"""Custom query filters for the Company app.""" + +from decimal import Decimal + +from django.db.models import DecimalField, ExpressionWrapper, F, Q +from django.db.models.functions import Coalesce + +from sql_util.utils import SubquerySum + +from order.status_codes import PurchaseOrderStatusGroups + + +def annotate_on_order_quantity(): + """Annotate the 'on_order' quantity for each SupplierPart in a queryset. + + - This is the total quantity of parts on order from all open purchase orders + - Takes into account the 'received' quantity for each order line + """ + # Filter only 'active' purhase orders + # Filter only line with outstanding quantity + order_filter = Q( + order__status__in=PurchaseOrderStatusGroups.OPEN, quantity__gt=F('received') + ) + + return Coalesce( + SubquerySum( + ExpressionWrapper( + F('purchase_order_line_items__quantity') + - F('purchase_order_line_items__received'), + output_field=DecimalField(), + ), + filter=order_filter, + ), + Decimal(0), + output_field=DecimalField(), + ) diff --git a/src/backend/InvenTree/company/serializers.py b/src/backend/InvenTree/company/serializers.py index da763f8d7b73..dd05243454d7 100644 --- a/src/backend/InvenTree/company/serializers.py +++ b/src/backend/InvenTree/company/serializers.py @@ -9,6 +9,7 @@ from sql_util.utils import SubqueryCount from taggit.serializers import TagListSerializerField +import company.filters import part.filters import part.serializers as part_serializers from importer.mixins import DataImportExportSerializerMixin @@ -323,6 +324,7 @@ class Meta: 'availability_updated', 'description', 'in_stock', + 'on_order', 'link', 'active', 'manufacturer', @@ -396,6 +398,8 @@ def __init__(self, *args, **kwargs): # Annotated field showing total in-stock quantity in_stock = serializers.FloatField(read_only=True, label=_('In Stock')) + on_order = serializers.FloatField(read_only=True, label=_('On Order')) + available = serializers.FloatField(required=False, label=_('Available')) pack_quantity_native = serializers.FloatField(read_only=True) @@ -442,6 +446,10 @@ def annotate_queryset(queryset): """ queryset = queryset.annotate(in_stock=part.filters.annotate_total_stock()) + queryset = queryset.annotate( + on_order=company.filters.annotate_on_order_quantity() + ) + return queryset def update(self, supplier_part, data): diff --git a/src/backend/InvenTree/part/filters.py b/src/backend/InvenTree/part/filters.py index 2a35b80714ab..b7b53a293a7b 100644 --- a/src/backend/InvenTree/part/filters.py +++ b/src/backend/InvenTree/part/filters.py @@ -1,4 +1,4 @@ -"""Custom query filters for the Part models. +"""Custom query filters for the Part app. The code here makes heavy use of subquery annotations! diff --git a/src/frontend/src/pages/company/SupplierPartDetail.tsx b/src/frontend/src/pages/company/SupplierPartDetail.tsx index 9d95f3a8f185..231520f2be3a 100644 --- a/src/frontend/src/pages/company/SupplierPartDetail.tsx +++ b/src/frontend/src/pages/company/SupplierPartDetail.tsx @@ -183,10 +183,25 @@ export default function SupplierPartDetail() { ]; const br: DetailsField[] = [ + { + type: 'string', + name: 'in_stock', + label: t`In Stock`, + copy: true, + icon: 'stock' + }, + { + type: 'string', + name: 'on_order', + label: t`On Order`, + copy: true, + icon: 'purchase_orders' + }, { type: 'string', name: 'available', label: t`Supplier Availability`, + hidden: !data.availability_updated, copy: true, icon: 'packages' }, @@ -352,6 +367,28 @@ export default function SupplierPartDetail() { label={t`Inactive`} color='red' visible={supplierPart.active == false} + />, + 0 + } + key='in_stock' + />, + , + 0} + key='on_order' /> ]; }, [supplierPart]); diff --git a/src/frontend/src/tables/purchasing/SupplierPartTable.tsx b/src/frontend/src/tables/purchasing/SupplierPartTable.tsx index 1e23746805c7..f8219cdf8f40 100644 --- a/src/frontend/src/tables/purchasing/SupplierPartTable.tsx +++ b/src/frontend/src/tables/purchasing/SupplierPartTable.tsx @@ -199,6 +199,11 @@ export function SupplierPartTable({ name: 'supplier_active', label: t`Active Supplier`, description: t`Show active suppliers` + }, + { + name: 'has_stock', + label: t`In Stock`, + description: t`Show supplier parts with stock` } ]; }, []);