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(() => {