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:
@ -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."""
|
||||
|
||||
|
Reference in New Issue
Block a user