diff --git a/docs/docs/part/scheduling.md b/docs/docs/part/scheduling.md deleted file mode 100644 index 199075d259..0000000000 --- a/docs/docs/part/scheduling.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -title: Part Scheduling ---- - -## Part Scheduling - - -The *Scheduling* tab provides an overview of the *predicted* future available quantity of a particular part. - -The *Scheduling* tab displays a chart of estimated future part stock levels. It begins at the current date, with the current stock level. It then projects into the "future", taking information from: - -#### Incoming Stock - -- **Purchase Orders** - Incoming goods will increase stock levels -- **Build Orders** - Completed build outputs will increase stock levels - -#### Outgoing Stock - -- **Sales Orders** - Outgoing stock items will reduce stock levels -- **Build Orders** - Allocated stock items will reduce stock levels - -#### Caveats - -The scheduling information only works as an adequate predictor of future stock quantity if there is sufficient information available in the database. - -In particular, stock movements due to orders (Purchase Orders / Sales Orders / Build Orders) will only be counted in the scheduling *if a target date is set for the order*. If the order does not have a target date set, we cannot know *when* (in the future) the stock levels will be adjusted. Thus, orders without target date information do not contribute to the scheduling information. - -Additionally, any orders with a target date in the "past" are also ignored for the purpose of part scheduling. - -Finally, any unexpected or unscheduled stock operations which are not associated with future orders cannot be predicted or displayed in the scheduling tab. - -{{ image("part/scheduling.png", "Part Scheduling View") }} diff --git a/docs/docs/part/views.md b/docs/docs/part/views.md index cf5bd850b7..8d927a243b 100644 --- a/docs/docs/part/views.md +++ b/docs/docs/part/views.md @@ -107,10 +107,6 @@ This tab is only displayed if the part is marked as *Purchaseable*. The *Sales Orders* tab shows a list of the sales orders for this part. It provides a view for important sales order information like customer, status, creation and shipment dates. -### Scheduling - -The *Scheduling* tab provides an overview of the *predicted* future availability of a particular part. Refer to the [scheduling documentation](./scheduling.md) for further information. - ### Stocktake The *Stocktake* tab provide historical stock level information, based on user-provided stocktake data. Refer to the [stocktake documentation](./stocktake.md) for further information. diff --git a/docs/docs/settings/user.md b/docs/docs/settings/user.md index 69d1026d98..a49ee19f8f 100644 --- a/docs/docs/settings/user.md +++ b/docs/docs/settings/user.md @@ -23,7 +23,6 @@ The *Display Settings* screen shows general display configuration options: {{ usersetting("DATE_DISPLAY_FORMAT") }} {{ usersetting("FORMS_CLOSE_USING_ESCAPE") }} {{ usersetting("PART_SHOW_QUANTITY_IN_FORMS") }} -{{ usersetting("DISPLAY_SCHEDULE_TAB") }} {{ usersetting("DISPLAY_STOCKTAKE_TAB") }} {{ usersetting("ENABLE_LAST_BREADCRUMB") }} diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 6472fb8906..63096e1dcf 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -152,7 +152,6 @@ nav: - Templates: part/template.md - Tests: part/test.md - Pricing: part/pricing.md - - Scheduling: part/scheduling.md - Stocktake: part/stocktake.md - Notifications: part/notification.md - Stock: diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 3ea7e8c2d1..bb726466ea 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 354 +INVENTREE_API_VERSION = 355 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v355 -> 2025-06-20 : https://github.com/inventree/InvenTree/pull/9811 + - Removes legacy "PartScheduling" API endpoints + v354 -> 2025-06-09 : https://github.com/inventree/InvenTree/pull/9532 - Adds "merge" field to the ReportTemplate model diff --git a/src/backend/InvenTree/common/setting/user.py b/src/backend/InvenTree/common/setting/user.py index 1c1f8db3c3..644f75b75e 100644 --- a/src/backend/InvenTree/common/setting/user.py +++ b/src/backend/InvenTree/common/setting/user.py @@ -211,12 +211,6 @@ USER_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { ('MMM DD YYYY', 'Feb 22 2022'), ], }, - 'DISPLAY_SCHEDULE_TAB': { - 'name': _('Part Scheduling'), - 'description': _('Display part scheduling information'), - 'default': True, - 'validator': bool, - }, 'DISPLAY_STOCKTAKE_TAB': { 'name': _('Part Stocktake'), 'description': _( diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index a64bde2cdd..53a4b5c167 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -1,8 +1,6 @@ """Provides a JSON API for the Part app.""" -import functools import re -from datetime import datetime from django.db.models import Count, F, Q from django.urls import include, path @@ -17,10 +15,7 @@ from rest_framework.exceptions import ValidationError from rest_framework.response import Response import InvenTree.permissions -import order.models import part.filters -from build.models import Build, BuildItem -from build.status_codes import BuildStatusGroups from data_exporter.mixins import DataExportViewMixin from InvenTree.api import BulkUpdateMixin, ListCreateDestroyAPIView, MetadataView from InvenTree.filters import ( @@ -43,7 +38,6 @@ from InvenTree.mixins import ( UpdateAPI, ) from InvenTree.serializers import EmptySerializer -from order.status_codes import PurchaseOrderStatusGroups, SalesOrderStatusGroups from stock.models import StockLocation from . import serializers as part_serializers @@ -547,205 +541,6 @@ class PartThumbsUpdate(RetrieveUpdateAPI): filter_backends = [DjangoFilterBackend] -class PartScheduling(RetrieveAPI): - """API endpoint for delivering "scheduling" information about a given part via the API. - - Returns a chronologically ordered list about future "scheduled" events, - concerning stock levels for the part: - - - Purchase Orders (incoming stock) - - Sales Orders (outgoing stock) - - Build Orders (incoming completed stock) - - Build Orders (outgoing allocated stock) - """ - - queryset = Part.objects.all() - serializer_class = part_serializers.PartSchedulingSerializer - - def retrieve(self, request, *args, **kwargs): - """Return scheduling information for the referenced Part instance.""" - part = self.get_object() - - schedule = [] - - def add_schedule_entry( - date: datetime, - quantity: float, - title: str, - instance, - speculative_quantity: float = 0, - ): - """Add a new entry to the schedule list. - - Args: - date (datetime): The date of the scheduled event. - quantity (float): The quantity of stock to be added or removed. - title (str): The title of the scheduled event. - instance (Model): The associated model instance (e.g., SalesOrder object). - speculative_quantity (float, optional): A speculative quantity to be added or removed. Defaults to 0. - """ - schedule.append({ - 'date': date, - 'quantity': quantity, - 'speculative_quantity': speculative_quantity, - 'title': title, - 'label': str(instance.reference), - 'model': instance.__class__.__name__.lower(), - 'model_id': instance.pk, - }) - - # Add purchase order (incoming stock) information - po_lines = order.models.PurchaseOrderLineItem.objects.filter( - part__part=part, order__status__in=PurchaseOrderStatusGroups.OPEN - ) - - for line in po_lines: - target_date = line.target_date or line.order.target_date - - line_quantity = max(line.quantity - line.received, 0) - - # Multiply by the pack quantity of the SupplierPart - quantity = line.part.base_quantity(line_quantity) - - add_schedule_entry( - target_date, quantity, _('Incoming Purchase Order'), line.order - ) - - # Add sales order (outgoing stock) information - so_lines = order.models.SalesOrderLineItem.objects.filter( - part=part, order__status__in=SalesOrderStatusGroups.OPEN - ) - - for line in so_lines: - target_date = line.target_date or line.order.target_date - - quantity = max(line.quantity - line.shipped, 0) - - add_schedule_entry( - target_date, -quantity, _('Outgoing Sales Order'), line.order - ) - - # Add build orders (incoming stock) information - build_orders = Build.objects.filter( - part=part, status__in=BuildStatusGroups.ACTIVE_CODES - ) - - for build in build_orders: - quantity = max(build.quantity - build.completed, 0) - - add_schedule_entry( - build.target_date, quantity, _('Stock produced by Build Order'), build - ) - - """ - Add build order allocation (outgoing stock) information. - - Here we need some careful consideration: - - - 'Tracked' stock items are removed from stock when the individual Build Output is completed - - 'Untracked' stock items are removed from stock when the Build Order is completed - - The 'simplest' approach here is to look at existing BuildItem allocations which reference this part, - and "schedule" them for removal at the time of build order completion. - - This assumes that the user is responsible for correctly allocating parts. - - However, it has the added benefit of side-stepping the various BOM substitution options, - and just looking at what stock items the user has actually allocated against the Build. - """ - - # Grab a list of BomItem objects that this part might be used in - bom_items = BomItem.objects.filter(part.get_used_in_bom_item_filter()) - - # Track all outstanding build orders - seen_builds = set() - - for bom_item in bom_items: - # Find a list of active builds for this BomItem - - if bom_item.inherited: - # An "inherited" BOM item filters down to variant parts also - children = bom_item.part.get_descendants(include_self=True) - builds = Build.objects.filter( - status__in=BuildStatusGroups.ACTIVE_CODES, part__in=children - ) - else: - builds = Build.objects.filter( - status__in=BuildStatusGroups.ACTIVE_CODES, part=bom_item.part - ) - - for build in builds: - # Ensure we don't double-count any builds - if build in seen_builds: - continue - - seen_builds.add(build) - - if bom_item.sub_part.trackable: - # Trackable parts are allocated against the outputs - required_quantity = build.remaining * bom_item.quantity - else: - # Non-trackable parts are allocated against the build itself - required_quantity = build.quantity * bom_item.quantity - - # Grab all allocations against the specified BomItem - allocations = BuildItem.objects.filter( - build_line__bom_item=bom_item, build_line__build=build - ) - - # Total allocated for *this* part - part_allocated_quantity = 0 - - # Total allocated for *any* part - total_allocated_quantity = 0 - - for allocation in allocations: - total_allocated_quantity += allocation.quantity - - if allocation.stock_item.part == part: - part_allocated_quantity += allocation.quantity - - speculative_quantity = 0 - - # Consider the case where the build order is *not* fully allocated - if required_quantity > total_allocated_quantity: - speculative_quantity = -1 * ( - required_quantity - total_allocated_quantity - ) - - add_schedule_entry( - build.target_date, - -part_allocated_quantity, - _('Stock required for Build Order'), - build, - speculative_quantity=speculative_quantity, - ) - - def compare(entry_1, entry_2): - """Comparison function for sorting entries by date. - - Account for the fact that either date might be None - """ - date_1 = entry_1['date'] - date_2 = entry_2['date'] - - if date_1 is None: - return -1 - elif date_2 is None: - return 1 - - return -1 if date_1 < date_2 else 1 - - # Sort by incrementing date values - schedules = sorted(schedule, key=functools.cmp_to_key(compare)) - - serializers = part_serializers.PartSchedulingSerializer( - schedules, many=True, context={'request': request} - ) - - return Response(serializers.data) - - class PartRequirements(RetrieveAPI): """API endpoint detailing 'requirements' information for a particular part. @@ -2210,8 +2005,6 @@ part_api_urls = [ PartSerialNumberDetail.as_view(), name='api-part-serial-number-detail', ), - # Endpoint for future scheduling information - path('scheduling/', PartScheduling.as_view(), name='api-part-scheduling'), path( 'requirements/', PartRequirements.as_view(), diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 3cb7051c8b..465d3cfd92 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -253,39 +253,6 @@ 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/part/test_api.py b/src/backend/InvenTree/part/test_api.py index 790bfd912b..4c5b91f594 100644 --- a/src/backend/InvenTree/part/test_api.py +++ b/src/backend/InvenTree/part/test_api.py @@ -3063,22 +3063,6 @@ class PartMetadataAPITest(InvenTreeAPITestCase): self.metatester(apikey, model) -class PartSchedulingTest(PartAPITestBase): - """Unit tests for the 'part scheduling' API endpoint.""" - - def test_get_schedule(self): - """Test that the scheduling endpoint returns OK.""" - part_ids = [1, 3, 100, 101] - - for pk in part_ids: - url = reverse('api-part-scheduling', kwargs={'pk': pk}) - data = self.get(url, expected_code=200).data - - for entry in data: - for k in ['date', 'quantity', 'label']: - self.assertIn(k, entry) - - class PartTestTemplateTest(PartAPITestBase): """API unit tests for the PartTestTemplate model.""" diff --git a/src/frontend/src/pages/Index/Settings/UserSettings.tsx b/src/frontend/src/pages/Index/Settings/UserSettings.tsx index 5975fc461e..cda57dcbfb 100644 --- a/src/frontend/src/pages/Index/Settings/UserSettings.tsx +++ b/src/frontend/src/pages/Index/Settings/UserSettings.tsx @@ -54,7 +54,6 @@ export default function UserSettings() { 'DATE_DISPLAY_FORMAT', 'FORMS_CLOSE_USING_ESCAPE', 'PART_SHOW_QUANTITY_IN_FORMS', - 'DISPLAY_SCHEDULE_TAB', 'DISPLAY_STOCKTAKE_TAB', 'ENABLE_LAST_BREADCRUMB' ]} diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 8e4671f548..85922fd400 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -11,7 +11,6 @@ import { import { IconBookmarks, IconBuilding, - IconCalendarStats, IconClipboardList, IconCurrencyDollar, IconInfoCircle, @@ -102,7 +101,6 @@ import { SalesOrderTable } from '../../tables/sales/SalesOrderTable'; import { StockItemTable } from '../../tables/stock/StockItemTable'; import PartAllocationPanel from './PartAllocationPanel'; import PartPricingPanel from './PartPricingPanel'; -import PartSchedulingDetail from './PartSchedulingDetail'; import PartStocktakeDetail from './PartStocktakeDetail'; import PartSupplierDetail from './PartSupplierDetail'; @@ -652,13 +650,6 @@ export default function PartDetail() { !globalSettings.isSet('STOCKTAKE_ENABLE') || !userSettings.isSet('DISPLAY_STOCKTAKE_TAB') }, - { - name: 'scheduling', - label: t`Scheduling`, - icon: , - content: part ? : , - hidden: !userSettings.isSet('DISPLAY_SCHEDULE_TAB') - }, { name: 'test_templates', label: t`Test Templates`, diff --git a/src/frontend/src/pages/part/PartSchedulingDetail.tsx b/src/frontend/src/pages/part/PartSchedulingDetail.tsx deleted file mode 100644 index cbcb04d6e8..0000000000 --- a/src/frontend/src/pages/part/PartSchedulingDetail.tsx +++ /dev/null @@ -1,315 +0,0 @@ -import { t } from '@lingui/core/macro'; -import { type ChartTooltipProps, LineChart } from '@mantine/charts'; -import { - Alert, - Center, - Divider, - Loader, - Paper, - SimpleGrid, - Text -} from '@mantine/core'; -import { type ReactNode, useMemo } from 'react'; -import { useNavigate } from 'react-router-dom'; - -import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; -import { apiUrl } from '@lib/functions/Api'; -import { getDetailUrl } from '@lib/functions/Navigation'; -import { navigateToLink } from '@lib/functions/Navigation'; -import dayjs from 'dayjs'; -import { formatDate } from '../../defaults/formatters'; -import { useTable } from '../../hooks/UseTable'; -import type { 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 }: Readonly) { - if (!payload) { - return null; - } - - if (label && typeof label == 'number') { - label = formatDate(dayjs().format('YYYY-MM-DD')); - } - - 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 -}: Readonly<{ part: any }>) { - const table = useTable('part-scheduling'); - const navigate = useNavigate(); - - const tableColumns: TableColumn[] = useMemo(() => { - return [ - { - accessor: 'label', - switchable: false, - title: t`Order` - }, - DescriptionColumn({ - accessor: 'title', - switchable: false - }), - DateColumn({ - sortable: false, - switchable: false - }), - { - accessor: 'quantity', - title: t`Quantity`, - switchable: false, - render: (record: any) => { - let q = record.quantity; - const 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) => { - const 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) - const entries: any[] = [ - { - date: today.valueOf(), - delta: 0, - scheduled: stock, - minimum: stock_min, - maximum: stock_max, - low_stock: part.minimum_stock - } - ]; - - table.records.forEach((record) => { - const 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]); - - const hasSchedulingInfo: boolean = useMemo( - () => table.recordCount > 0, - [table.recordCount] - ); - - return ( - <> - {!table.isLoading && !hasSchedulingInfo && ( - - {t`There is no scheduling information available for the selected part`} - - )} - - { - const url = getDetailUrl(record.model, record.model_id); - - if (url) { - navigateToLink(url, navigate, event); - } - } - }} - /> - {table.isLoading ? ( -
- -
- ) : ( - ( - - ) - }} - yAxisLabel={t`Expected Quantity`} - xAxisLabel={t`Date`} - xAxisProps={{ - domain: chartLimits, - scale: 'time', - type: 'number', - tickFormatter: (value: number) => { - return formatDate(dayjs().format('YYYY-MM-DD')); - } - }} - 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/tests/pages/pui_part.spec.ts b/src/frontend/tests/pages/pui_part.spec.ts index f0232fb342..fe6aba0197 100644 --- a/src/frontend/tests/pages/pui_part.spec.ts +++ b/src/frontend/tests/pages/pui_part.spec.ts @@ -32,7 +32,6 @@ test('Parts - Tabs', async ({ browser }) => { await loadTab(page, 'Pricing'); await loadTab(page, 'Suppliers'); await loadTab(page, 'Purchase Orders'); - await loadTab(page, 'Scheduling'); await loadTab(page, 'Stock History'); await loadTab(page, 'Attachments'); await loadTab(page, 'Notes');