mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-29 20:16:44 +00:00
[PUI] Supplier part badges (#8625)
* API fixes for SupplierPart - Move API filtering into SupplierPartFilter class - Correct field annotation for detail view * Add "in stock" and "no stock" badges to SupplierPart detail * Update details * Annotate 'on_order' quantity for SupplierPart * Add "has_stock" filter to SupplierPart API * Improve API query efficiency * Add 'has_stock' filter to table * Update <SupplierPartDetail> * Bump API version
This commit is contained in:
parent
9ab18f1da7
commit
1a8b030819
@ -1,13 +1,17 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
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
|
v291 - 2024-11-30 : https://github.com/inventree/InvenTree/pull/8596
|
||||||
- Allow null / empty values for plugin settings
|
- Allow null / empty values for plugin settings
|
||||||
|
|
||||||
|
@ -282,6 +282,7 @@ class SupplierPartFilter(rest_filters.FilterSet):
|
|||||||
field_name='part__active', label=_('Internal Part is Active')
|
field_name='part__active', label=_('Internal Part is Active')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Filter by 'active' status of linked supplier
|
||||||
supplier_active = rest_filters.BooleanFilter(
|
supplier_active = rest_filters.BooleanFilter(
|
||||||
field_name='supplier__active', label=_('Supplier is Active')
|
field_name='supplier__active', label=_('Supplier is Active')
|
||||||
)
|
)
|
||||||
@ -293,43 +294,48 @@ class SupplierPartFilter(rest_filters.FilterSet):
|
|||||||
lookup_expr='iexact',
|
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):
|
# Filter by 'company' (either manufacturer or supplier)
|
||||||
"""API endpoint for list view of SupplierPart object.
|
company = rest_filters.ModelChoiceFilter(
|
||||||
|
label=_('Company'), queryset=Company.objects.all(), method='filter_company'
|
||||||
|
)
|
||||||
|
|
||||||
- GET: Return list of SupplierPart objects
|
def filter_company(self, queryset, name, value):
|
||||||
- POST: Create a new SupplierPart object
|
"""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')
|
queryset = SupplierPart.objects.all().prefetch_related('tags')
|
||||||
filterset_class = SupplierPartFilter
|
serializer_class = SupplierPartSerializer
|
||||||
|
|
||||||
def get_queryset(self, *args, **kwargs):
|
def get_queryset(self, *args, **kwargs):
|
||||||
"""Return annotated queryest object for the SupplierPart list."""
|
"""Return annotated queryest object for the SupplierPart list."""
|
||||||
queryset = super().get_queryset(*args, **kwargs)
|
queryset = super().get_queryset(*args, **kwargs)
|
||||||
queryset = SupplierPartSerializer.annotate_queryset(queryset)
|
queryset = SupplierPartSerializer.annotate_queryset(queryset)
|
||||||
|
|
||||||
return queryset
|
queryset = queryset.prefetch_related('part', 'part__pricing_data')
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
@ -351,7 +357,17 @@ class SupplierPartList(DataExportViewMixin, ListCreateDestroyAPIView):
|
|||||||
|
|
||||||
return self.serializer_class(*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
|
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||||
|
|
||||||
@ -391,7 +407,7 @@ class SupplierPartList(DataExportViewMixin, ListCreateDestroyAPIView):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class SupplierPartDetail(RetrieveUpdateDestroyAPI):
|
class SupplierPartDetail(SupplierPartMixin, RetrieveUpdateDestroyAPI):
|
||||||
"""API endpoint for detail view of SupplierPart object.
|
"""API endpoint for detail view of SupplierPart object.
|
||||||
|
|
||||||
- GET: Retrieve detail view
|
- GET: Retrieve detail view
|
||||||
@ -399,11 +415,6 @@ class SupplierPartDetail(RetrieveUpdateDestroyAPI):
|
|||||||
- DELETE: Delete object
|
- DELETE: Delete object
|
||||||
"""
|
"""
|
||||||
|
|
||||||
queryset = SupplierPart.objects.all()
|
|
||||||
serializer_class = SupplierPartSerializer
|
|
||||||
|
|
||||||
read_only_fields = []
|
|
||||||
|
|
||||||
|
|
||||||
class SupplierPriceBreakFilter(rest_filters.FilterSet):
|
class SupplierPriceBreakFilter(rest_filters.FilterSet):
|
||||||
"""Custom API filters for the SupplierPriceBreak list endpoint."""
|
"""Custom API filters for the SupplierPriceBreak list endpoint."""
|
||||||
|
36
src/backend/InvenTree/company/filters.py
Normal file
36
src/backend/InvenTree/company/filters.py
Normal file
@ -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(),
|
||||||
|
)
|
@ -9,6 +9,7 @@ from rest_framework import serializers
|
|||||||
from sql_util.utils import SubqueryCount
|
from sql_util.utils import SubqueryCount
|
||||||
from taggit.serializers import TagListSerializerField
|
from taggit.serializers import TagListSerializerField
|
||||||
|
|
||||||
|
import company.filters
|
||||||
import part.filters
|
import part.filters
|
||||||
import part.serializers as part_serializers
|
import part.serializers as part_serializers
|
||||||
from importer.mixins import DataImportExportSerializerMixin
|
from importer.mixins import DataImportExportSerializerMixin
|
||||||
@ -323,6 +324,7 @@ class SupplierPartSerializer(
|
|||||||
'availability_updated',
|
'availability_updated',
|
||||||
'description',
|
'description',
|
||||||
'in_stock',
|
'in_stock',
|
||||||
|
'on_order',
|
||||||
'link',
|
'link',
|
||||||
'active',
|
'active',
|
||||||
'manufacturer',
|
'manufacturer',
|
||||||
@ -396,6 +398,8 @@ class SupplierPartSerializer(
|
|||||||
# Annotated field showing total in-stock quantity
|
# Annotated field showing total in-stock quantity
|
||||||
in_stock = serializers.FloatField(read_only=True, label=_('In Stock'))
|
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'))
|
available = serializers.FloatField(required=False, label=_('Available'))
|
||||||
|
|
||||||
pack_quantity_native = serializers.FloatField(read_only=True)
|
pack_quantity_native = serializers.FloatField(read_only=True)
|
||||||
@ -442,6 +446,10 @@ class SupplierPartSerializer(
|
|||||||
"""
|
"""
|
||||||
queryset = queryset.annotate(in_stock=part.filters.annotate_total_stock())
|
queryset = queryset.annotate(in_stock=part.filters.annotate_total_stock())
|
||||||
|
|
||||||
|
queryset = queryset.annotate(
|
||||||
|
on_order=company.filters.annotate_on_order_quantity()
|
||||||
|
)
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
def update(self, supplier_part, data):
|
def update(self, supplier_part, data):
|
||||||
|
@ -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!
|
The code here makes heavy use of subquery annotations!
|
||||||
|
|
||||||
|
@ -183,10 +183,25 @@ export default function SupplierPartDetail() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const br: DetailsField[] = [
|
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',
|
type: 'string',
|
||||||
name: 'available',
|
name: 'available',
|
||||||
label: t`Supplier Availability`,
|
label: t`Supplier Availability`,
|
||||||
|
hidden: !data.availability_updated,
|
||||||
copy: true,
|
copy: true,
|
||||||
icon: 'packages'
|
icon: 'packages'
|
||||||
},
|
},
|
||||||
@ -352,6 +367,28 @@ export default function SupplierPartDetail() {
|
|||||||
label={t`Inactive`}
|
label={t`Inactive`}
|
||||||
color='red'
|
color='red'
|
||||||
visible={supplierPart.active == false}
|
visible={supplierPart.active == false}
|
||||||
|
/>,
|
||||||
|
<DetailsBadge
|
||||||
|
label={`${t`In Stock`}: ${supplierPart.in_stock}`}
|
||||||
|
color={'green'}
|
||||||
|
visible={
|
||||||
|
supplierPart?.active &&
|
||||||
|
supplierPart?.in_stock &&
|
||||||
|
supplierPart?.in_stock > 0
|
||||||
|
}
|
||||||
|
key='in_stock'
|
||||||
|
/>,
|
||||||
|
<DetailsBadge
|
||||||
|
label={t`No Stock`}
|
||||||
|
color={'red'}
|
||||||
|
visible={supplierPart.active && supplierPart.in_stock == 0}
|
||||||
|
key='no_stock'
|
||||||
|
/>,
|
||||||
|
<DetailsBadge
|
||||||
|
label={`${t`On Order`}: ${supplierPart.on_order}`}
|
||||||
|
color='blue'
|
||||||
|
visible={supplierPart.on_order > 0}
|
||||||
|
key='on_order'
|
||||||
/>
|
/>
|
||||||
];
|
];
|
||||||
}, [supplierPart]);
|
}, [supplierPart]);
|
||||||
|
@ -199,6 +199,11 @@ export function SupplierPartTable({
|
|||||||
name: 'supplier_active',
|
name: 'supplier_active',
|
||||||
label: t`Active Supplier`,
|
label: t`Active Supplier`,
|
||||||
description: t`Show active suppliers`
|
description: t`Show active suppliers`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'has_stock',
|
||||||
|
label: t`In Stock`,
|
||||||
|
description: t`Show supplier parts with stock`
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}, []);
|
}, []);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user