mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 13:15:43 +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