Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PUI] Supplier part badges #8625

Merged
merged 9 commits into from
Dec 3, 2024
Merged
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
Loading