mirror of
https://github.com/inventree/InvenTree.git
synced 2025-07-02 03:30: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.
|
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
|
### 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.
|
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("DATE_DISPLAY_FORMAT") }}
|
||||||
{{ usersetting("FORMS_CLOSE_USING_ESCAPE") }}
|
{{ usersetting("FORMS_CLOSE_USING_ESCAPE") }}
|
||||||
{{ usersetting("PART_SHOW_QUANTITY_IN_FORMS") }}
|
{{ usersetting("PART_SHOW_QUANTITY_IN_FORMS") }}
|
||||||
{{ usersetting("DISPLAY_SCHEDULE_TAB") }}
|
|
||||||
{{ usersetting("DISPLAY_STOCKTAKE_TAB") }}
|
{{ usersetting("DISPLAY_STOCKTAKE_TAB") }}
|
||||||
{{ usersetting("ENABLE_LAST_BREADCRUMB") }}
|
{{ usersetting("ENABLE_LAST_BREADCRUMB") }}
|
||||||
|
|
||||||
|
@ -152,7 +152,6 @@ nav:
|
|||||||
- Templates: part/template.md
|
- Templates: part/template.md
|
||||||
- Tests: part/test.md
|
- Tests: part/test.md
|
||||||
- Pricing: part/pricing.md
|
- Pricing: part/pricing.md
|
||||||
- Scheduling: part/scheduling.md
|
|
||||||
- Stocktake: part/stocktake.md
|
- Stocktake: part/stocktake.md
|
||||||
- Notifications: part/notification.md
|
- Notifications: part/notification.md
|
||||||
- Stock:
|
- Stock:
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
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
|
v354 -> 2025-06-09 : https://github.com/inventree/InvenTree/pull/9532
|
||||||
- Adds "merge" field to the ReportTemplate model
|
- Adds "merge" field to the ReportTemplate model
|
||||||
|
|
||||||
|
@ -211,12 +211,6 @@ USER_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
|||||||
('MMM DD YYYY', 'Feb 22 2022'),
|
('MMM DD YYYY', 'Feb 22 2022'),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'DISPLAY_SCHEDULE_TAB': {
|
|
||||||
'name': _('Part Scheduling'),
|
|
||||||
'description': _('Display part scheduling information'),
|
|
||||||
'default': True,
|
|
||||||
'validator': bool,
|
|
||||||
},
|
|
||||||
'DISPLAY_STOCKTAKE_TAB': {
|
'DISPLAY_STOCKTAKE_TAB': {
|
||||||
'name': _('Part Stocktake'),
|
'name': _('Part Stocktake'),
|
||||||
'description': _(
|
'description': _(
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
"""Provides a JSON API for the Part app."""
|
"""Provides a JSON API for the Part app."""
|
||||||
|
|
||||||
import functools
|
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from django.db.models import Count, F, Q
|
from django.db.models import Count, F, Q
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
@ -17,10 +15,7 @@ from rest_framework.exceptions import ValidationError
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
import InvenTree.permissions
|
import InvenTree.permissions
|
||||||
import order.models
|
|
||||||
import part.filters
|
import part.filters
|
||||||
from build.models import Build, BuildItem
|
|
||||||
from build.status_codes import BuildStatusGroups
|
|
||||||
from data_exporter.mixins import DataExportViewMixin
|
from data_exporter.mixins import DataExportViewMixin
|
||||||
from InvenTree.api import BulkUpdateMixin, ListCreateDestroyAPIView, MetadataView
|
from InvenTree.api import BulkUpdateMixin, ListCreateDestroyAPIView, MetadataView
|
||||||
from InvenTree.filters import (
|
from InvenTree.filters import (
|
||||||
@ -43,7 +38,6 @@ from InvenTree.mixins import (
|
|||||||
UpdateAPI,
|
UpdateAPI,
|
||||||
)
|
)
|
||||||
from InvenTree.serializers import EmptySerializer
|
from InvenTree.serializers import EmptySerializer
|
||||||
from order.status_codes import PurchaseOrderStatusGroups, SalesOrderStatusGroups
|
|
||||||
from stock.models import StockLocation
|
from stock.models import StockLocation
|
||||||
|
|
||||||
from . import serializers as part_serializers
|
from . import serializers as part_serializers
|
||||||
@ -547,205 +541,6 @@ class PartThumbsUpdate(RetrieveUpdateAPI):
|
|||||||
filter_backends = [DjangoFilterBackend]
|
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):
|
class PartRequirements(RetrieveAPI):
|
||||||
"""API endpoint detailing 'requirements' information for a particular part.
|
"""API endpoint detailing 'requirements' information for a particular part.
|
||||||
|
|
||||||
@ -2210,8 +2005,6 @@ part_api_urls = [
|
|||||||
PartSerialNumberDetail.as_view(),
|
PartSerialNumberDetail.as_view(),
|
||||||
name='api-part-serial-number-detail',
|
name='api-part-serial-number-detail',
|
||||||
),
|
),
|
||||||
# Endpoint for future scheduling information
|
|
||||||
path('scheduling/', PartScheduling.as_view(), name='api-part-scheduling'),
|
|
||||||
path(
|
path(
|
||||||
'requirements/',
|
'requirements/',
|
||||||
PartRequirements.as_view(),
|
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):
|
class PartThumbSerializer(serializers.Serializer):
|
||||||
"""Serializer for the 'image' field of the Part model.
|
"""Serializer for the 'image' field of the Part model.
|
||||||
|
|
||||||
|
@ -3063,22 +3063,6 @@ class PartMetadataAPITest(InvenTreeAPITestCase):
|
|||||||
self.metatester(apikey, model)
|
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):
|
class PartTestTemplateTest(PartAPITestBase):
|
||||||
"""API unit tests for the PartTestTemplate model."""
|
"""API unit tests for the PartTestTemplate model."""
|
||||||
|
|
||||||
|
@ -54,7 +54,6 @@ export default function UserSettings() {
|
|||||||
'DATE_DISPLAY_FORMAT',
|
'DATE_DISPLAY_FORMAT',
|
||||||
'FORMS_CLOSE_USING_ESCAPE',
|
'FORMS_CLOSE_USING_ESCAPE',
|
||||||
'PART_SHOW_QUANTITY_IN_FORMS',
|
'PART_SHOW_QUANTITY_IN_FORMS',
|
||||||
'DISPLAY_SCHEDULE_TAB',
|
|
||||||
'DISPLAY_STOCKTAKE_TAB',
|
'DISPLAY_STOCKTAKE_TAB',
|
||||||
'ENABLE_LAST_BREADCRUMB'
|
'ENABLE_LAST_BREADCRUMB'
|
||||||
]}
|
]}
|
||||||
|
@ -11,7 +11,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
IconBookmarks,
|
IconBookmarks,
|
||||||
IconBuilding,
|
IconBuilding,
|
||||||
IconCalendarStats,
|
|
||||||
IconClipboardList,
|
IconClipboardList,
|
||||||
IconCurrencyDollar,
|
IconCurrencyDollar,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
@ -102,7 +101,6 @@ import { SalesOrderTable } from '../../tables/sales/SalesOrderTable';
|
|||||||
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
||||||
import PartAllocationPanel from './PartAllocationPanel';
|
import PartAllocationPanel from './PartAllocationPanel';
|
||||||
import PartPricingPanel from './PartPricingPanel';
|
import PartPricingPanel from './PartPricingPanel';
|
||||||
import PartSchedulingDetail from './PartSchedulingDetail';
|
|
||||||
import PartStocktakeDetail from './PartStocktakeDetail';
|
import PartStocktakeDetail from './PartStocktakeDetail';
|
||||||
import PartSupplierDetail from './PartSupplierDetail';
|
import PartSupplierDetail from './PartSupplierDetail';
|
||||||
|
|
||||||
@ -652,13 +650,6 @@ export default function PartDetail() {
|
|||||||
!globalSettings.isSet('STOCKTAKE_ENABLE') ||
|
!globalSettings.isSet('STOCKTAKE_ENABLE') ||
|
||||||
!userSettings.isSet('DISPLAY_STOCKTAKE_TAB')
|
!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',
|
name: 'test_templates',
|
||||||
label: t`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, 'Pricing');
|
||||||
await loadTab(page, 'Suppliers');
|
await loadTab(page, 'Suppliers');
|
||||||
await loadTab(page, 'Purchase Orders');
|
await loadTab(page, 'Purchase Orders');
|
||||||
await loadTab(page, 'Scheduling');
|
|
||||||
await loadTab(page, 'Stock History');
|
await loadTab(page, 'Stock History');
|
||||||
await loadTab(page, 'Attachments');
|
await loadTab(page, 'Attachments');
|
||||||
await loadTab(page, 'Notes');
|
await loadTab(page, 'Notes');
|
||||||
|
Reference in New Issue
Block a user