Skip to content

Commit

Permalink
[PUI] Supplier part badges (#8625)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
SchrodingersGat authored Dec 3, 2024
1 parent 9ab18f1 commit 1a8b030
Show file tree
Hide file tree
Showing 7 changed files with 137 additions and 36 deletions.
6 changes: 5 additions & 1 deletion src/backend/InvenTree/InvenTree/api_version.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
79 changes: 45 additions & 34 deletions src/backend/InvenTree/company/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
)
Expand All @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -391,19 +407,14 @@ 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
- PATCH: Update object
- 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."""
Expand Down
36 changes: 36 additions & 0 deletions src/backend/InvenTree/company/filters.py
Original file line number Diff line number Diff line change
@@ -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(),
)
8 changes: 8 additions & 0 deletions src/backend/InvenTree/company/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -323,6 +324,7 @@ class Meta:
'availability_updated',
'description',
'in_stock',
'on_order',
'link',
'active',
'manufacturer',
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion src/backend/InvenTree/part/filters.py
Original file line number Diff line number Diff line change
@@ -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!
Expand Down
37 changes: 37 additions & 0 deletions src/frontend/src/pages/company/SupplierPartDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
},
Expand Down Expand Up @@ -352,6 +367,28 @@ export default function SupplierPartDetail() {
label={t`Inactive`}
color='red'
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]);
Expand Down
5 changes: 5 additions & 0 deletions src/frontend/src/tables/purchasing/SupplierPartTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
}
];
}, []);
Expand Down

0 comments on commit 1a8b030

Please sign in to comment.