2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-01 03:00:54 +00:00

[Breaking] Remove part scheduling feature (#9811)

* Remove frontend code

* Remove references to setting

* Remove API endpoint

* Docs updates

* Bump API version

* Remove check for old tab
This commit is contained in:
Oliver
2025-06-20 17:17:44 +10:00
committed by GitHub
parent c90fc2feda
commit b4f3fd46f9
13 changed files with 4 additions and 627 deletions

View File

@ -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") }}

View File

@ -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.

View File

@ -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") }}

View File

@ -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:

View File

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

View File

@ -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': _(

View File

@ -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(),

View File

@ -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.

View File

@ -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."""

View File

@ -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'
]}

View File

@ -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: <IconCalendarStats />,
content: part ? <PartSchedulingDetail part={part} /> : <Skeleton />,
hidden: !userSettings.isSet('DISPLAY_SCHEDULE_TAB')
},
{
name: 'test_templates',
label: t`Test Templates`,

View File

@ -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<ChartTooltipProps>) {
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 (
<Paper px='md' py='sm' withBorder shadow='md' radius='md'>
<Text key='title'>{label}</Text>
<Divider />
<Text key='maximum' c={maximum?.color} fz='sm'>
{t`Maximum`} : {maximum?.value}
</Text>
<Text key='scheduled' c={scheduled?.color} fz='sm'>
{t`Scheduled`} : {scheduled?.value}
</Text>
<Text key='minimum' c={minimum?.color} fz='sm'>
{t`Minimum`} : {minimum?.value}
</Text>
</Paper>
);
}
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(
<Text
size='sm'
key={'speculative'}
>{t`Quantity is speculative`}</Text>
);
}
if (!record.date) {
extra.push(
<Text
key={'null-date'}
size='sm'
>{t`No date available for provided quantity`}</Text>
);
} else if (new Date(record.date) < new Date()) {
extra.push(
<Text size='sm' key={'past-date'}>{t`Date is in the past`}</Text>
);
}
return (
<TableHoverCard
value={<Text key='quantity'>{q}</Text>}
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 && (
<Alert color='blue' title={t`No information available`}>
<Text>{t`There is no scheduling information available for the selected part`}</Text>
</Alert>
)}
<SimpleGrid cols={{ base: 1, md: 2 }}>
<InvenTreeTable
url={apiUrl(ApiEndpoints.part_scheduling, part.pk)}
tableState={table}
columns={tableColumns}
props={{
enableSearch: false,
onRowClick: (record: any, index: number, event: any) => {
const url = getDetailUrl(record.model, record.model_id);
if (url) {
navigateToLink(url, navigate, event);
}
}
}}
/>
{table.isLoading ? (
<Center>
<Loader />
</Center>
) : (
<LineChart
data={chartData}
mah={'500px'}
dataKey='date'
withLegend
withYAxis
tooltipProps={{
content: ({ label, payload }) => (
<ChartTooltip label={label} payload={payload} />
)
}}
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'
}
]}
/>
)}
</SimpleGrid>
</>
);
}

View File

@ -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');