diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 751cd4d0ee..868672faba 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 = 257 +INVENTREE_API_VERSION = 258 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v258 - 2024-09-24 : https://github.com/inventree/InvenTree/pull/8163 + - Enhances the existing PartScheduling API endpoint + - Adds a formal DRF serializer to the endpoint + v257 - 2024-09-22 : https://github.com/inventree/InvenTree/pull/8150 - Adds API endpoint for reporting barcode scan history diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index 1285378de2..4bb10ecf60 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -559,7 +559,7 @@ class PartScheduling(RetrieveAPI): """ queryset = Part.objects.all() - serializer_class = EmptySerializer + serializer_class = part_serializers.PartSchedulingSerializer def retrieve(self, request, *args, **kwargs): """Return scheduling information for the referenced Part instance.""" @@ -567,23 +567,24 @@ class PartScheduling(RetrieveAPI): schedule = [] - def add_schedule_entry( - date, quantity, title, label, url, speculative_quantity=0 - ): - """Check if a scheduled entry should be added. + def add_schedule_entry(date, quantity, title, instance, speculative_quantity=0): + """Add a new entry to the schedule list. - Rules: - - date must be non-null - - date cannot be in the "past" - - quantity must not be zero + Arguments: + - date: The date of the scheduled event + - quantity: The quantity of stock to be added or removed + - title: The title of the scheduled event + - instance: The associated model instance (e.g. SalesOrder object) + - speculative_quantity: A speculative quantity to be added or removed """ schedule.append({ 'date': date, 'quantity': quantity, 'speculative_quantity': speculative_quantity, 'title': title, - 'label': label, - 'url': url, + 'label': str(instance.reference), + 'model': instance.__class__.__name__.lower(), + 'model_id': instance.pk, }) # Add purchase order (incoming stock) information @@ -600,11 +601,7 @@ class PartScheduling(RetrieveAPI): quantity = line.part.base_quantity(line_quantity) add_schedule_entry( - target_date, - quantity, - _('Incoming Purchase Order'), - str(line.order), - line.order.get_absolute_url(), + target_date, quantity, _('Incoming Purchase Order'), line.order ) # Add sales order (outgoing stock) information @@ -618,11 +615,7 @@ class PartScheduling(RetrieveAPI): quantity = max(line.quantity - line.shipped, 0) add_schedule_entry( - target_date, - -quantity, - _('Outgoing Sales Order'), - str(line.order), - line.order.get_absolute_url(), + target_date, -quantity, _('Outgoing Sales Order'), line.order ) # Add build orders (incoming stock) information @@ -634,11 +627,7 @@ class PartScheduling(RetrieveAPI): quantity = max(build.quantity - build.completed, 0) add_schedule_entry( - build.target_date, - quantity, - _('Stock produced by Build Order'), - str(build), - build.get_absolute_url(), + build.target_date, quantity, _('Stock produced by Build Order'), build ) """ @@ -721,8 +710,7 @@ class PartScheduling(RetrieveAPI): build.target_date, -part_allocated_quantity, _('Stock required for Build Order'), - str(build), - build.get_absolute_url(), + build, speculative_quantity=speculative_quantity, ) @@ -742,9 +730,13 @@ class PartScheduling(RetrieveAPI): return -1 if date_1 < date_2 else 1 # Sort by incrementing date values - schedule = sorted(schedule, key=functools.cmp_to_key(compare)) + schedules = sorted(schedule, key=functools.cmp_to_key(compare)) - return Response(schedule) + serializers = part_serializers.PartSchedulingSerializer( + schedules, many=True, context={'request': request} + ) + + return Response(serializers.data) class PartRequirements(RetrieveAPI): diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index c520ca2abd..ffc935abb3 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -244,6 +244,39 @@ class PartInternalPriceSerializer(InvenTree.serializers.InvenTreeModelSerializer ) +class PartSchedulingSerializer(serializers.Serializer): + """Serializer class for a PartScheduling entry.""" + + class Meta: + """Metaclass options for this serializer.""" + + fields = [ + 'date', + 'quantity', + 'speculative_quantity', + 'title', + 'label', + 'model', + 'model_id', + ] + + date = serializers.DateField(label=_('Date'), required=True, allow_null=True) + + quantity = serializers.FloatField(label=_('Quantity'), required=True) + + speculative_quantity = serializers.FloatField( + label=_('Speculative Quantity'), required=False + ) + + title = serializers.CharField(label=_('Title'), required=True) + + label = serializers.CharField(label=_('Label'), required=True) + + model = serializers.CharField(label=_('Model'), required=True) + + model_id = serializers.IntegerField(label=_('Model ID'), required=True) + + class PartThumbSerializer(serializers.Serializer): """Serializer for the 'image' field of the Part model. diff --git a/src/backend/InvenTree/templates/js/translated/part.js b/src/backend/InvenTree/templates/js/translated/part.js index 9080b6b27d..a065aad924 100644 --- a/src/backend/InvenTree/templates/js/translated/part.js +++ b/src/backend/InvenTree/templates/js/translated/part.js @@ -3078,10 +3078,26 @@ function loadPartSchedulingChart(canvas_id, part_id) { quantity_string += makeIconBadge('fa-question-circle icon-blue', '{% trans "Speculative" %}'); } + let url = '#'; + + switch (entry.model) { + case 'salesorder': + url = `/order/sales-order/${entry.model_id}/`; + break; + case 'purchaseorder': + url = `/order/purchase-order/${entry.model_id}/`; + break; + case 'build': + url = `/build/${entry.model_id}/`; + break; + default: + break; + } + // Add an entry to the scheduling table table_html += ` - ${entry.label} + ${entry.label} ${entry.title} ${date_string} ${quantity_string} diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index 68f6846013..4295f70b0f 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -95,6 +95,7 @@ export enum ApiEndpoints { part_thumbs_list = 'part/thumbs/', part_pricing_get = 'part/:id/pricing/', part_serial_numbers = 'part/:id/serial-numbers/', + part_scheduling = 'part/:id/scheduling/', part_pricing_internal = 'part/internal-price/', part_pricing_sale = 'part/sale-price/', part_stocktake_list = 'part/stocktake/', diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index d34764497f..c6758b035d 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -104,6 +104,7 @@ import { SalesOrderTable } from '../../tables/sales/SalesOrderTable'; import { StockItemTable } from '../../tables/stock/StockItemTable'; import { TestStatisticsTable } from '../../tables/stock/TestStatisticsTable'; import PartPricingPanel from './PartPricingPanel'; +import PartSchedulingDetail from './PartSchedulingDetail'; import PartStocktakeDetail from './PartStocktakeDetail'; /** @@ -705,7 +706,7 @@ export default function PartDetail() { name: 'scheduling', label: t`Scheduling`, icon: , - content: , + content: part ? : , hidden: !userSettings.isSet('DISPLAY_SCHEDULE_TAB') }, { diff --git a/src/frontend/src/pages/part/PartSchedulingDetail.tsx b/src/frontend/src/pages/part/PartSchedulingDetail.tsx new file mode 100644 index 0000000000..8121f3426f --- /dev/null +++ b/src/frontend/src/pages/part/PartSchedulingDetail.tsx @@ -0,0 +1,312 @@ +import { t } from '@lingui/macro'; +import { ChartTooltipProps, LineChart } from '@mantine/charts'; +import { + Anchor, + Center, + Divider, + DrawerOverlay, + Loader, + Paper, + SimpleGrid, + Text +} from '@mantine/core'; +import { ReactNode, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { formatDate } from '../../defaults/formatters'; +import { ApiEndpoints } from '../../enums/ApiEndpoints'; +import { navigateToLink } from '../../functions/navigation'; +import { getDetailUrl } from '../../functions/urls'; +import { useTable } from '../../hooks/UseTable'; +import { apiUrl } from '../../states/ApiState'; +import { TableColumn } from '../../tables/Column'; +import { DateColumn, DescriptionColumn } from '../../tables/ColumnRenderers'; +import { InvenTreeTable } from '../../tables/InvenTreeTable'; +import { TableHoverCard } from '../../tables/TableHoverCard'; + +/* + * Render a tooltip for the chart, with correct date information + */ +function ChartTooltip({ label, payload }: ChartTooltipProps) { + if (!payload) { + return null; + } + + if (label && typeof label == 'number') { + label = formatDate(new Date(label).toISOString()); + } + + const scheduled = payload.find((item) => item.name == 'scheduled'); + const minimum = payload.find((item) => item.name == 'minimum'); + const maximum = payload.find((item) => item.name == 'maximum'); + + return ( + + {label} + + + {t`Maximum`} : {maximum?.value} + + + {t`Scheduled`} : {scheduled?.value} + + + {t`Minimum`} : {minimum?.value} + + + ); +} + +export default function PartSchedulingDetail({ part }: { part: any }) { + const table = useTable('part-scheduling'); + const navigate = useNavigate(); + + const tableColumns: TableColumn[] = useMemo(() => { + return [ + { + accessor: 'label', + switchable: false, + title: t`Order`, + render: (record: any) => { + const url = getDetailUrl(record.model, record.model_id); + + if (url) { + return ( + navigateToLink(url, navigate, event)} + > + {record.label} + + ); + } else { + return record.label; + } + } + }, + DescriptionColumn({ + accessor: 'title', + switchable: false + }), + DateColumn({ + sortable: false, + switchable: false + }), + { + accessor: 'quantity', + title: t`Quantity`, + switchable: false, + render: (record: any) => { + let q = record.quantity; + let extra: ReactNode[] = []; + + if (record.speculative_quantity != 0) { + q = record.speculative_quantity; + extra.push( + {t`Quantity is speculative`} + ); + } + + if (!record.date) { + extra.push( + {t`No date available for provided quantity`} + ); + } else if (new Date(record.date) < new Date()) { + extra.push( + {t`Date is in the past`} + ); + } + + return ( + {q}} + title={t`Scheduled Quantity`} + extra={extra} + /> + ); + } + } + ]; + }, []); + + const chartData = useMemo(() => { + /* Rebuild chart data whenever the table data changes. + * Note: We assume that the data is provided in increasing date order, + * with "null" date entries placed first. + */ + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // Date bounds + let min_date: Date = new Date(); + let max_date: Date = new Date(); + + // Track stock scheduling throughout time + let stock = part.in_stock ?? 0; + let stock_min = stock; + let stock_max = stock; + + // First, iterate through each entry and find any entries without an associated date, or in the past + table.records.forEach((record) => { + let q = record.quantity + record.speculative_quantity; + + if (record.date == null || new Date(record.date) < today) { + if (q < 0) { + stock_min += q; + } else { + stock_max += q; + } + } + }); + + // Construct initial chart entry (for today) + let entries: any[] = [ + { + // date: formatDate(today.toISOString()), + date: today.valueOf(), + delta: 0, + scheduled: stock, + minimum: stock_min, + maximum: stock_max, + low_stock: part.minimum_stock + } + ]; + + table.records.forEach((record) => { + let q = record.quantity + record.speculative_quantity; + + if (!record.date) { + return; + } + + const date = new Date(record.date); + + // In the past? Ignore this entry + if (date < today) { + return; + } + + // Update date limits + + if (date < min_date) { + min_date = date; + } + + if (date > max_date) { + max_date = date; + } + + // Update stock levels + stock += record.quantity; + + stock_min += record.quantity; + stock_max += record.quantity; + + // Speculative quantities expand the expected stock range + if (record.speculative_quantity < 0) { + stock_min += record.speculative_quantity; + } else if (record.speculative_quantity > 0) { + stock_max += record.speculative_quantity; + } + + entries.push({ + ...record, + date: new Date(record.date).valueOf(), + scheduled: stock, + minimum: stock_min, + maximum: stock_max, + low_stock: part.minimum_stock + }); + }); + + return entries; + }, [part, table.records]); + + // Calculate the date limits of the chart + const chartLimits: number[] = useMemo(() => { + let min_date = new Date(); + let max_date = new Date(); + + if (chartData.length > 0) { + min_date = new Date(chartData[0].date); + max_date = new Date(chartData[chartData.length - 1].date); + } + + // Expand limits by one day on either side + min_date.setDate(min_date.getDate() - 1); + max_date.setDate(max_date.getDate() + 1); + + return [min_date.valueOf(), max_date.valueOf()]; + }, [chartData]); + + return ( + <> + + + {table.isLoading ? ( +
+ +
+ ) : ( + ( + + ) + }} + yAxisLabel={t`Expected Quantity`} + xAxisLabel={t`Date`} + xAxisProps={{ + domain: [chartLimits[0], chartLimits[1]], + scale: 'time', + type: 'number', + tickFormatter: (value: number) => { + return formatDate(new Date(value).toISOString()); + } + }} + series={[ + { + name: 'scheduled', + label: t`Scheduled`, + color: 'blue.6' + }, + { + name: 'minimum', + label: t`Minimum`, + color: 'yellow.6' + }, + { + name: 'maximum', + label: t`Maximum`, + color: 'teal.6' + }, + { + name: 'low_stock', + label: t`Low Stock`, + color: 'red.6' + } + ]} + /> + )} +
+ + ); +} diff --git a/src/frontend/src/tables/InvenTreeTable.tsx b/src/frontend/src/tables/InvenTreeTable.tsx index 72cd10da0b..f5e283c28c 100644 --- a/src/frontend/src/tables/InvenTreeTable.tsx +++ b/src/frontend/src/tables/InvenTreeTable.tsx @@ -500,7 +500,7 @@ export function InvenTreeTable>({ }); }; - const { data, isFetching, refetch } = useQuery({ + const { data, isFetching, isLoading, refetch } = useQuery({ queryKey: [ tableState.page, props.params, @@ -515,8 +515,13 @@ export function InvenTreeTable>({ }); useEffect(() => { - tableState.setIsLoading(isFetching); - }, [isFetching]); + tableState.setIsLoading( + isFetching || + isLoading || + tableOptionQuery.isFetching || + tableOptionQuery.isLoading + ); + }, [isFetching, isLoading, tableOptionQuery]); // Update tableState.records when new data received useEffect(() => {