mirror of
https://github.com/inventree/InvenTree.git
synced 2025-07-01 11:10: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:
@ -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") }}
|
@ -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.
|
||||
|
@ -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") }}
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
||||
|
@ -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': _(
|
||||
|
@ -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(),
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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'
|
||||
]}
|
||||
|
@ -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`,
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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');
|
||||
|
Reference in New Issue
Block a user