From 4e100643f5c7e89a9c7a649ec1e6e7b3dc25d74e Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 1 Apr 2022 17:24:15 +1100 Subject: [PATCH 01/19] Add annotation for "allocated_to_sales_orders" --- InvenTree/part/serializers.py | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index c46950adca..dafb4a846e 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -24,7 +24,10 @@ from InvenTree.serializers import (DataFileUploadSerializer, InvenTreeAttachmentSerializer, InvenTreeMoneySerializer) -from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus +from InvenTree.status_codes import (BuildStatus, + PurchaseOrderStatus, + SalesOrderStatus) + from stock.models import StockItem from .models import (BomItem, BomItemSubstitute, @@ -363,6 +366,26 @@ class PartSerializer(InvenTreeModelSerializer): ), ) + """ + Annotate with the number of stock items allocated to sales orders. + This annotation is modeled on Part.sales_order_allocations() method: + + - Only look for "open" orders + - Stock items have not been "shipped" + """ + so_allocation_filter = Q( + line__order__status__in=SalesOrderStatus.OPEN, # LineItem points to an OPEN order + shipment=None, # Allocated item has *not* been shipped out + ) + + queryset = queryset.annotate( + allocated_to_sales_orders=Coalesce( + SubquerySum('stock_items__sales_order_allocations__quantity', filter=so_allocation_filter), + Decimal(0), + output_field=models.DecimalField(), + ) + ) + return queryset def get_starred(self, part): @@ -376,9 +399,10 @@ class PartSerializer(InvenTreeModelSerializer): category_detail = CategorySerializer(source='category', many=False, read_only=True) # Calculated fields + allocated_to_sales_orders = serializers.FloatField(read_only=True) + building = serializers.FloatField(read_only=True) in_stock = serializers.FloatField(read_only=True) ordering = serializers.FloatField(read_only=True) - building = serializers.FloatField(read_only=True) stock_item_count = serializers.IntegerField(read_only=True) suppliers = serializers.IntegerField(read_only=True) @@ -399,7 +423,7 @@ class PartSerializer(InvenTreeModelSerializer): partial = True fields = [ 'active', - + 'allocated_to_sales_orders', 'assembly', 'category', 'category_detail', From 94068f6bad0f710f1c2397eb783f308cf77940bc Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 1 Apr 2022 17:31:01 +1100 Subject: [PATCH 02/19] Adds annotation for build order allocation count --- InvenTree/part/serializers.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index dafb4a846e..21b095ddc2 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -386,6 +386,22 @@ class PartSerializer(InvenTreeModelSerializer): ) ) + """ + Annotate with the number of stock items allocated to build orders. + This annotation is modeled on Part.build_order_allocations() method + """ + bo_allocation_filter = Q( + build__status__in=BuildStatus.ACTIVE_CODES, + ) + + queryset = queryset.annotate( + allocated_to_build_orders=Coalesce( + SubquerySum('stock_items__allocations__quantity', filter=bo_allocation_filter), + Decimal(0), + output_field=models.DecimalField(), + ) + ) + return queryset def get_starred(self, part): @@ -399,6 +415,7 @@ class PartSerializer(InvenTreeModelSerializer): category_detail = CategorySerializer(source='category', many=False, read_only=True) # Calculated fields + allocated_to_build_orders = serializers.FloatField(read_only=True) allocated_to_sales_orders = serializers.FloatField(read_only=True) building = serializers.FloatField(read_only=True) in_stock = serializers.FloatField(read_only=True) @@ -423,6 +440,7 @@ class PartSerializer(InvenTreeModelSerializer): partial = True fields = [ 'active', + 'allocated_to_build_orders', 'allocated_to_sales_orders', 'assembly', 'category', From bf6a9b69ce4ebfd96c91ceab8e850949b2be53f5 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 1 Apr 2022 17:44:05 +1100 Subject: [PATCH 03/19] Annotate with "unallocated_stock" quantity (calculated field) --- InvenTree/part/serializers.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 21b095ddc2..c352c59eab 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -7,7 +7,7 @@ from decimal import Decimal from django.urls import reverse_lazy from django.db import models, transaction -from django.db.models import Q +from django.db.models import ExpressionWrapper, F, Q from django.db.models.functions import Coalesce from django.utils.translation import ugettext_lazy as _ @@ -375,7 +375,7 @@ class PartSerializer(InvenTreeModelSerializer): """ so_allocation_filter = Q( line__order__status__in=SalesOrderStatus.OPEN, # LineItem points to an OPEN order - shipment=None, # Allocated item has *not* been shipped out + shipment__shipment_date=None, # Allocated item has *not* been shipped out ) queryset = queryset.annotate( @@ -402,6 +402,15 @@ class PartSerializer(InvenTreeModelSerializer): ) ) + # Annotate with the total 'available stock' quantity + # This is the current stock, minus any allocations + queryset = queryset.annotate( + unallocated_stock=ExpressionWrapper( + F('in_stock') - F('allocated_to_sales_orders') - F('allocated_to_build_orders'), + output_field=models.DecimalField(), + ) + ) + return queryset def get_starred(self, part): @@ -417,6 +426,7 @@ class PartSerializer(InvenTreeModelSerializer): # Calculated fields allocated_to_build_orders = serializers.FloatField(read_only=True) allocated_to_sales_orders = serializers.FloatField(read_only=True) + unallocated_stock = serializers.FloatField(read_only=True) building = serializers.FloatField(read_only=True) in_stock = serializers.FloatField(read_only=True) ordering = serializers.FloatField(read_only=True) @@ -472,6 +482,7 @@ class PartSerializer(InvenTreeModelSerializer): 'suppliers', 'thumbnail', 'trackable', + 'unallocated_stock', 'units', 'variant_of', 'virtual', From 9ad260e8e0ccdab156e382e10771b77ca83eb282 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 1 Apr 2022 17:46:15 +1100 Subject: [PATCH 04/19] Bump API version --- InvenTree/InvenTree/version.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index 8d9b0e8da1..fe28d780c7 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -12,11 +12,15 @@ import common.models INVENTREE_SW_VERSION = "0.7.0 dev" # InvenTree API version -INVENTREE_API_VERSION = 34 +INVENTREE_API_VERSION = 35 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v35 -> 2022-04-01 : https://github.com/inventree/InvenTree/pull/2797 + - Adds stock allocation information to the Part API + - Adds calculated field for "unallocated_quantity" + v34 -> 2022-03-25 - Change permissions for "plugin list" API endpoint (now allows any authenticated user) From f9a0bf822e2e54c3e8f01cdecc42cd38c3dbfcc8 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 2 Apr 2022 09:49:32 +1100 Subject: [PATCH 05/19] Add more fixture data - Stock items for part 100 (Bob) --- InvenTree/part/fixtures/bom.yaml | 8 +++ InvenTree/part/fixtures/part.yaml | 12 ++++ InvenTree/stock/fixtures/stock.yaml | 101 ++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+) diff --git a/InvenTree/part/fixtures/bom.yaml b/InvenTree/part/fixtures/bom.yaml index e879b8381f..facb7e76ae 100644 --- a/InvenTree/part/fixtures/bom.yaml +++ b/InvenTree/part/fixtures/bom.yaml @@ -38,3 +38,11 @@ part: 1 sub_part: 5 quantity: 3 + +# Make "Assembly" from "Bob" +- model: part.bomitem + pk: 6 + fields: + part: 101 + sub_part: 100 + quantity: 10 diff --git a/InvenTree/part/fixtures/part.yaml b/InvenTree/part/fixtures/part.yaml index 77e808fd7f..d0a2d949b1 100644 --- a/InvenTree/part/fixtures/part.yaml +++ b/InvenTree/part/fixtures/part.yaml @@ -108,6 +108,18 @@ lft: 0 rght: 0 +- model: part.part + pk: 101 + fields: + name: 'Assembly' + description: 'A high level assembly' + salable: true + active: True + tree_id: 0 + level: 0 + lft: 0 + rght: 0 + # A 'template' part - model: part.part pk: 10000 diff --git a/InvenTree/stock/fixtures/stock.yaml b/InvenTree/stock/fixtures/stock.yaml index 0f44828d8e..2fd5b7eb92 100644 --- a/InvenTree/stock/fixtures/stock.yaml +++ b/InvenTree/stock/fixtures/stock.yaml @@ -251,3 +251,104 @@ rght: 0 expiry_date: "1990-10-10" status: 70 + +# Multiple stock items for "Bob" (PK 100) +- model: stock.stockitem + pk: 1000 + fields: + part: 100 + location: 1 + quantity: 10 + level: 0 + tree_id: 0 + lft: 0 + rght: 0 + +- model: stock.stockitem + pk: 1001 + fields: + part: 100 + location: 1 + quantity: 11 + level: 0 + tree_id: 0 + lft: 0 + rght: 0 + +- model: stock.stockitem + pk: 1002 + fields: + part: 100 + location: 1 + quantity: 12 + level: 0 + tree_id: 0 + lft: 0 + rght: 0 + +- model: stock.stockitem + pk: 1003 + fields: + part: 100 + location: 1 + quantity: 13 + level: 0 + tree_id: 0 + lft: 0 + rght: 0 + +- model: stock.stockitem + pk: 1004 + fields: + part: 100 + location: 1 + quantity: 14 + level: 0 + tree_id: 0 + lft: 0 + rght: 0 + +- model: stock.stockitem + pk: 1005 + fields: + part: 100 + location: 1 + quantity: 15 + level: 0 + tree_id: 0 + lft: 0 + rght: 0 + +- model: stock.stockitem + pk: 1006 + fields: + part: 100 + location: 1 + quantity: 16 + level: 0 + tree_id: 0 + lft: 0 + rght: 0 + +- model: stock.stockitem + pk: 1007 + fields: + part: 100 + location: 7 + quantity: 17 + level: 0 + tree_id: 0 + lft: 0 + rght: 0 + +- model: stock.stockitem + pk: 1008 + fields: + part: 100 + location: 7 + quantity: 18 + level: 0 + tree_id: 0 + lft: 0 + rght: 0 + \ No newline at end of file From 57be4b337dc3a596abb7d51b8b02c1d3c3a7ba06 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 2 Apr 2022 09:55:37 +1100 Subject: [PATCH 06/19] Check that new annotated fields exist in the API dataset --- InvenTree/part/test_api.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 23f929bca0..4d84d0f7ca 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -815,6 +815,10 @@ class PartAPIAggregationTest(InvenTreeAPITestCase): 'location', 'bom', 'test_templates', + 'build', + 'location', + 'stock', + 'sales_order', ] roles = [ @@ -880,6 +884,27 @@ class PartAPIAggregationTest(InvenTreeAPITestCase): self.assertEqual(data['in_stock'], 1100) self.assertEqual(data['stock_item_count'], 105) + def test_allocation_annotations(self): + """ + Tests for query annotations which add allocation information. + Ref: https://github.com/inventree/InvenTree/pull/2797 + """ + + # We are looking at Part ID 100 ("Bob") + url = reverse('api-part-detail', kwargs={'pk': 100}) + + response = self.get(url, expected_code=200) + + # Check that the expected annotated fields exist in the data + data = response.data + self.assertEqual(data['allocated_to_build_orders'], 0) + self.assertEqual(data['allocated_to_sales_orders'], 0) + + # The unallocated stock count should equal the 'in stock' coutn + in_stock = data['in_stock'] + self.assertEqual(in_stock, 126) + self.assertEqual(data['unallocated_stock'], in_stock) + class BomItemTest(InvenTreeAPITestCase): """ From 484a0693d7502d73c44aa2d98135923d08cfdce4 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 2 Apr 2022 10:08:21 +1100 Subject: [PATCH 07/19] Check that the sales_order_allocations information is provided in the API endpoint --- InvenTree/part/test_api.py | 113 ++++++++++++++++++++++++++++++++++++- 1 file changed, 112 insertions(+), 1 deletion(-) diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 4d84d0f7ca..133ebee0e1 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -9,7 +9,7 @@ from rest_framework import status from rest_framework.test import APIClient from InvenTree.api_tester import InvenTreeAPITestCase -from InvenTree.status_codes import StockStatus +from InvenTree.status_codes import BuildStatus, StockStatus from part.models import Part, PartCategory from part.models import BomItem, BomItemSubstitute @@ -17,6 +17,9 @@ from stock.models import StockItem, StockLocation from company.models import Company from common.models import InvenTreeSetting +import build.models +import order.models + class PartOptionsAPITest(InvenTreeAPITestCase): """ @@ -893,6 +896,8 @@ class PartAPIAggregationTest(InvenTreeAPITestCase): # We are looking at Part ID 100 ("Bob") url = reverse('api-part-detail', kwargs={'pk': 100}) + part = Part.objects.get(pk=100) + response = self.get(url, expected_code=200) # Check that the expected annotated fields exist in the data @@ -905,6 +910,112 @@ class PartAPIAggregationTest(InvenTreeAPITestCase): self.assertEqual(in_stock, 126) self.assertEqual(data['unallocated_stock'], in_stock) + # Now, let's create a sales order, and allocate some stock + so = order.models.SalesOrder.objects.create( + reference='001', + customer=Company.objects.get(pk=1), + ) + + # We wish to send 50 units of "Bob" against this sales order + line = order.models.SalesOrderLineItem.objects.create( + quantity=50, + order=so, + part=part, + ) + + # Create a shipment against the order + shipment_1 = order.models.SalesOrderShipment.objects.create( + order=so, + reference='001', + ) + + shipment_2 = order.models.SalesOrderShipment.objects.create( + order=so, + reference='002', + ) + + # Allocate stock items to this order, against multiple shipments + order.models.SalesOrderAllocation.objects.create( + line=line, + shipment=shipment_1, + item=StockItem.objects.get(pk=1007), + quantity=17 + ) + + order.models.SalesOrderAllocation.objects.create( + line=line, + shipment=shipment_1, + item=StockItem.objects.get(pk=1008), + quantity=18 + ) + + order.models.SalesOrderAllocation.objects.create( + line=line, + shipment=shipment_2, + item=StockItem.objects.get(pk=1006), + quantity=15, + ) + + # Submit the API request again - should show us the sales order allocation + data = self.get(url, expected_code=200).data + + self.assertEqual(data['allocated_to_sales_orders'], 50) + self.assertEqual(data['in_stock'], 126) + self.assertEqual(data['unallocated_stock'], 76) + + # Now, "ship" the first shipment (so the stock is not 'in stock' any more) + shipment_1.complete_shipment(None) + + # Refresh the API data + data = self.get(url, expected_code=200).data + + self.assertEqual(data['allocated_to_build_orders'], 0) + self.assertEqual(data['allocated_to_sales_orders'], 15) + self.assertEqual(data['in_stock'], 91) + self.assertEqual(data['unallocated_stock'], 76) + + # Next, we create a build order and allocate stock against it + bo = build.models.Build.objects.create( + part=Part.objects.get(pk=101), + quantity=10, + title='Making some assemblies', + status=BuildStatus.PRODUCTION, + ) + + bom_item = BomItem.objects.get(pk=6) + + # Allocate multiple stock items against this build order + build.models.BuildItem.objects.create( + build=bo, + bom_item=bom_item, + stock_item=StockItem.objects.get(pk=1000), + quantity=10, + ) + + # Request data once more + data = self.get(url, expected_code=200).data + + self.assertEqual(data['allocated_to_build_orders'], 10) + self.assertEqual(data['allocated_to_sales_orders'], 15) + self.assertEqual(data['in_stock'], 91) + self.assertEqual(data['unallocated_stock'], 66) + + # Allocate further stock against the build + build.models.BuildItem.objects.create( + build=bo, + bom_item=bom_item, + stock_item=StockItem.objects.get(pk=1001), + quantity=10, + ) + + # Request data once more + data = self.get(url, expected_code=200).data + + self.assertEqual(data['allocated_to_build_orders'], 20) + self.assertEqual(data['allocated_to_sales_orders'], 15) + self.assertEqual(data['in_stock'], 91) + self.assertEqual(data['unallocated_stock'], 56) + class BomItemTest(InvenTreeAPITestCase): """ From 4593b0f4127813cddbbcf1498fd4868fe5f3af95 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 2 Apr 2022 11:12:54 +1100 Subject: [PATCH 08/19] Unit test fixes --- InvenTree/part/test_api.py | 2 +- InvenTree/part/test_bom_item.py | 2 +- InvenTree/stock/test_api.py | 25 +++++++++++++------------ 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 133ebee0e1..56a32c2cb5 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -250,7 +250,7 @@ class PartAPITest(InvenTreeAPITestCase): data = {'cascade': True} response = self.client.get(url, data, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data), 13) + self.assertEqual(len(response.data), Part.objects.count()) def test_get_parts_by_cat(self): url = reverse('api-part-list') diff --git a/InvenTree/part/test_bom_item.py b/InvenTree/part/test_bom_item.py index 7466277118..88548f3cf7 100644 --- a/InvenTree/part/test_bom_item.py +++ b/InvenTree/part/test_bom_item.py @@ -46,7 +46,7 @@ class BomItemTest(TestCase): # TODO: Tests for multi-level BOMs def test_used_in(self): - self.assertEqual(self.bob.used_in_count, 0) + self.assertEqual(self.bob.used_in_count, 1) self.assertEqual(self.orphan.used_in_count, 1) def test_self_reference(self): diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index 81973aed31..e078c74189 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -104,7 +104,7 @@ class StockItemListTest(StockAPITestCase): response = self.get_stock() - self.assertEqual(len(response), 20) + self.assertEqual(len(response), 29) def test_filter_by_part(self): """ @@ -113,7 +113,7 @@ class StockItemListTest(StockAPITestCase): response = self.get_stock(part=25) - self.assertEqual(len(response), 8) + self.assertEqual(len(response), 17) response = self.get_stock(part=10004) @@ -136,10 +136,10 @@ class StockItemListTest(StockAPITestCase): self.assertEqual(len(response), 1) response = self.get_stock(location=1, cascade=0) - self.assertEqual(len(response), 0) + self.assertEqual(len(response), 7) response = self.get_stock(location=1, cascade=1) - self.assertEqual(len(response), 2) + self.assertEqual(len(response), 9) response = self.get_stock(location=7) self.assertEqual(len(response), 16) @@ -153,7 +153,7 @@ class StockItemListTest(StockAPITestCase): self.assertEqual(len(response), 1) response = self.get_stock(depleted=0) - self.assertEqual(len(response), 19) + self.assertEqual(len(response), 28) def test_filter_by_in_stock(self): """ @@ -161,7 +161,7 @@ class StockItemListTest(StockAPITestCase): """ response = self.get_stock(in_stock=1) - self.assertEqual(len(response), 17) + self.assertEqual(len(response), 26) response = self.get_stock(in_stock=0) self.assertEqual(len(response), 3) @@ -172,7 +172,7 @@ class StockItemListTest(StockAPITestCase): """ codes = { - StockStatus.OK: 18, + StockStatus.OK: 27, StockStatus.DESTROYED: 1, StockStatus.LOST: 1, StockStatus.DAMAGED: 0, @@ -205,7 +205,7 @@ class StockItemListTest(StockAPITestCase): self.assertIsNotNone(item['serial']) response = self.get_stock(serialized=0) - self.assertEqual(len(response), 8) + self.assertEqual(len(response), 17) for item in response: self.assertIsNone(item['serial']) @@ -217,7 +217,7 @@ class StockItemListTest(StockAPITestCase): # First, we can assume that the 'stock expiry' feature is disabled response = self.get_stock(expired=1) - self.assertEqual(len(response), 20) + self.assertEqual(len(response), 29) self.user.is_staff = True self.user.save() @@ -232,7 +232,7 @@ class StockItemListTest(StockAPITestCase): self.assertTrue(item['expired']) response = self.get_stock(expired=0) - self.assertEqual(len(response), 19) + self.assertEqual(len(response), 28) for item in response: self.assertFalse(item['expired']) @@ -290,7 +290,8 @@ class StockItemListTest(StockAPITestCase): dataset = self.export_data({}) - self.assertEqual(len(dataset), 20) + # Check that *all* stock item objects have been exported + self.assertEqual(len(dataset), StockItem.objects.count()) # Expected headers headers = [ @@ -308,7 +309,7 @@ class StockItemListTest(StockAPITestCase): # Now, add a filter to the results dataset = self.export_data({'location': 1}) - self.assertEqual(len(dataset), 2) + self.assertEqual(len(dataset), 9) dataset = self.export_data({'part': 25}) From 0ab2ff306c70611e3bcabfa4e679706e67026aa7 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 2 Apr 2022 11:13:43 +1100 Subject: [PATCH 09/19] Fix incorrect value for "available stock" --- InvenTree/part/models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index fad0f12e28..9d02f78ebe 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -1345,7 +1345,8 @@ class Part(MPTTModel): queryset = OrderModels.SalesOrderAllocation.objects.filter(item__part__id=self.id) - pending = kwargs.get('pending', None) + # Default behaviour is to only return *pending* allocations + pending = kwargs.get('pending', True) if pending is True: # Look only for 'open' orders which have not shipped From fb0b87db3d15d4cfa47322479a89aab7baa2b69b Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 2 Apr 2022 12:00:08 +1100 Subject: [PATCH 10/19] Further unit test fixes --- InvenTree/part/test_api.py | 18 ++++++++++++++++++ InvenTree/stock/test_api.py | 6 +++--- InvenTree/stock/tests.py | 4 ++-- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 56a32c2cb5..a813fcd832 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -910,6 +910,12 @@ class PartAPIAggregationTest(InvenTreeAPITestCase): self.assertEqual(in_stock, 126) self.assertEqual(data['unallocated_stock'], in_stock) + # Check that model functions return the same values + self.assertEqual(part.build_order_allocation_count(), 0) + self.assertEqual(part.sales_order_allocation_count(), 0) + self.assertEqual(part.total_stock, in_stock) + self.assertEqual(part.available_stock(), in_stock) + # Now, let's create a sales order, and allocate some stock so = order.models.SalesOrder.objects.create( reference='001', @@ -1000,6 +1006,12 @@ class PartAPIAggregationTest(InvenTreeAPITestCase): self.assertEqual(data['in_stock'], 91) self.assertEqual(data['unallocated_stock'], 66) + # Again, check that the direct model functions return the same values + self.assertEqual(part.build_order_allocation_count(), 10) + self.assertEqual(part.sales_order_allocation_count(), 15) + self.assertEqual(part.total_stock, 91) + self.assertEqual(part.available_stock(), 66) + # Allocate further stock against the build build.models.BuildItem.objects.create( build=bo, @@ -1016,6 +1028,12 @@ class PartAPIAggregationTest(InvenTreeAPITestCase): self.assertEqual(data['in_stock'], 91) self.assertEqual(data['unallocated_stock'], 56) + # Again, check that the direct model functions return the same values + self.assertEqual(part.build_order_allocation_count(), 20) + self.assertEqual(part.sales_order_allocation_count(), 15) + self.assertEqual(part.total_stock, 91) + self.assertEqual(part.available_stock(), 56) + class BomItemTest(InvenTreeAPITestCase): """ diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index e078c74189..73bee54110 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -142,7 +142,7 @@ class StockItemListTest(StockAPITestCase): self.assertEqual(len(response), 9) response = self.get_stock(location=7) - self.assertEqual(len(response), 16) + self.assertEqual(len(response), 18) def test_filter_by_depleted(self): """ @@ -249,7 +249,7 @@ class StockItemListTest(StockAPITestCase): self.assertEqual(len(response), 4) response = self.get_stock(expired=0) - self.assertEqual(len(response), 16) + self.assertEqual(len(response), 25) def test_paginate(self): """ @@ -313,7 +313,7 @@ class StockItemListTest(StockAPITestCase): dataset = self.export_data({'part': 25}) - self.assertEqual(len(dataset), 8) + self.assertEqual(len(dataset), 17) class StockItemTest(StockAPITestCase): diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py index 50f77a593b..97639b15bd 100644 --- a/InvenTree/stock/tests.py +++ b/InvenTree/stock/tests.py @@ -167,8 +167,8 @@ class StockTest(TestCase): self.assertFalse(self.drawer2.has_items()) # Drawer 3 should have three stock items - self.assertEqual(self.drawer3.stock_items.count(), 16) - self.assertEqual(self.drawer3.item_count, 16) + self.assertEqual(self.drawer3.stock_items.count(), 18) + self.assertEqual(self.drawer3.item_count, 18) def test_stock_count(self): part = Part.objects.get(pk=1) From 271cb3f8b1449e60ed5b8b90fc197bab77cdf0d3 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 2 Apr 2022 13:26:39 +1100 Subject: [PATCH 11/19] Rebuild tree structure before running unit tests! --- InvenTree/part/models.py | 2 +- InvenTree/part/test_api.py | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 9d02f78ebe..c493028d71 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -1434,7 +1434,7 @@ class Part(MPTTModel): - If this part is a "template" (variants exist) then these are counted too """ - return self.get_stock_count() + return self.get_stock_count(include_variants=True) def get_bom_item_filter(self, include_inherited=True): """ diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index a813fcd832..bea7154612 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -833,6 +833,9 @@ class PartAPIAggregationTest(InvenTreeAPITestCase): super().setUp() + # Ensure the part "variant" tree is correctly structured + Part.objects.rebuild() + # Add a new part self.part = Part.objects.create( name='Banana', @@ -914,7 +917,7 @@ class PartAPIAggregationTest(InvenTreeAPITestCase): self.assertEqual(part.build_order_allocation_count(), 0) self.assertEqual(part.sales_order_allocation_count(), 0) self.assertEqual(part.total_stock, in_stock) - self.assertEqual(part.available_stock(), in_stock) + self.assertEqual(part.available_stock, in_stock) # Now, let's create a sales order, and allocate some stock so = order.models.SalesOrder.objects.create( @@ -1010,7 +1013,7 @@ class PartAPIAggregationTest(InvenTreeAPITestCase): self.assertEqual(part.build_order_allocation_count(), 10) self.assertEqual(part.sales_order_allocation_count(), 15) self.assertEqual(part.total_stock, 91) - self.assertEqual(part.available_stock(), 66) + self.assertEqual(part.available_stock, 66) # Allocate further stock against the build build.models.BuildItem.objects.create( @@ -1032,7 +1035,7 @@ class PartAPIAggregationTest(InvenTreeAPITestCase): self.assertEqual(part.build_order_allocation_count(), 20) self.assertEqual(part.sales_order_allocation_count(), 15) self.assertEqual(part.total_stock, 91) - self.assertEqual(part.available_stock(), 56) + self.assertEqual(part.available_stock, 56) class BomItemTest(InvenTreeAPITestCase): From a9f7457c12bcd27a4bb5984b3c42fc05a56d591b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1lm=C3=A1n=20R=C3=B3zsahegyi?= Date: Sat, 2 Apr 2022 15:51:21 +0200 Subject: [PATCH 12/19] New part table filter on available stock --- InvenTree/InvenTree/version.py | 5 ++++- InvenTree/part/api.py | 14 ++++++++++++++ InvenTree/templates/js/translated/table_filters.js | 6 +++++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index fe28d780c7..0a9d39225b 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -12,11 +12,14 @@ import common.models INVENTREE_SW_VERSION = "0.7.0 dev" # InvenTree API version -INVENTREE_API_VERSION = 35 +INVENTREE_API_VERSION = 36 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v36 -> 2022-04-03 + - Adds ability to filter part list endpoint by unallocated_stock argument + v35 -> 2022-04-01 : https://github.com/inventree/InvenTree/pull/2797 - Adds stock allocation information to the Part API - Adds calculated field for "unallocated_quantity" diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 3a2bb6eeb3..f7bb81520d 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -798,6 +798,20 @@ class PartFilter(rest_filters.FilterSet): return queryset + # unallocated_stock filter + unallocated_stock = rest_filters.BooleanFilter(label='Unallocated stock', method='filter_unallocated_stock') + + def filter_unallocated_stock(self, queryset, name, value): + + value = str2bool(value) + + if value: + queryset = queryset.filter(Q(unallocated_stock__gt=0)) + else: + queryset = queryset.filter(Q(unallocated_stock__lte=0)) + + return queryset + is_template = rest_filters.BooleanFilter() assembly = rest_filters.BooleanFilter() diff --git a/InvenTree/templates/js/translated/table_filters.js b/InvenTree/templates/js/translated/table_filters.js index 81d43d2c3f..6212568950 100644 --- a/InvenTree/templates/js/translated/table_filters.js +++ b/InvenTree/templates/js/translated/table_filters.js @@ -427,12 +427,16 @@ function getAvailableTableFilters(tableKey) { }, has_stock: { type: 'bool', - title: '{% trans "Stock available" %}', + title: '{% trans "In stock" %}', }, low_stock: { type: 'bool', title: '{% trans "Low stock" %}', }, + unallocated_stock: { + type: 'bool', + title: '{% trans "Available stock" %}', + }, assembly: { type: 'bool', title: '{% trans "Assembly" %}', From d912846e41d496623f8d7f1f1dee2eea9dae8036 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1lm=C3=A1n=20R=C3=B3zsahegyi?= Date: Sat, 2 Apr 2022 15:58:04 +0200 Subject: [PATCH 13/19] Use available quantities in part table, enhance stock badge --- InvenTree/templates/js/translated/part.js | 52 +++++++++++++++-------- 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index b0283a1b35..16d6062565 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -1160,12 +1160,14 @@ function partGridTile(part) { if (!part.in_stock) { stock = `{% trans "No Stock" %}`; + } else if (!part.unallocated_stock) { + stock = `{% trans "Not available" %}`; } rows += `{% trans "Stock" %}${stock}`; - if (part.on_order) { - rows += `{$ trans "On Order" %}${part.on_order}`; + if (part.ordering) { + rows += `{% trans "On Order" %}${part.ordering}`; } if (part.building) { @@ -1322,31 +1324,47 @@ function loadPartTable(table, url, options={}) { columns.push(col); col = { - field: 'in_stock', - title: '{% trans "Stock" %}', + field: 'unallocated_stock', + title: '{% trans "Available" %}', searchable: false, formatter: function(value, row) { var link = '?display=part-stock'; - if (value) { + if (row.in_stock) { // There IS stock available for this part // Is stock "low" (below the 'minimum_stock' quantity)? - if (row.minimum_stock && row.minimum_stock > value) { + if (row.minimum_stock && row.minimum_stock > row.in_stock) { value += `{% trans "Low stock" %}`; + } else if (value == 0) { + if (row.ordering) { + // There is no available stock, but stock is on order + value = `0{% trans "On Order" %}: ${row.ordering}`; + link = '?display=purchase-orders'; + } else if (row.building) { + // There is no available stock, but stock is being built + value = `0{% trans "Building" %}: ${row.building}`; + link = '?display=build-orders'; + } else { + // There is no available stock + value = `0{% trans "Not available" %}`; + } } - - } else if (row.on_order) { - // There is no stock available, but stock is on order - value = `0{% trans "On Order" %}: ${row.on_order}`; - link = '?display=purchase-orders'; - } else if (row.building) { - // There is no stock available, but stock is being built - value = `0{% trans "Building" %}: ${row.building}`; - link = '?display=build-orders'; } else { - // There is no stock available - value = `0{% trans "No Stock" %}`; + // There IS NO stock available for this part + + if (row.ordering) { + // There is no stock, but stock is on order + value = `0{% trans "On Order" %}: ${row.ordering}`; + link = '?display=purchase-orders'; + } else if (row.building) { + // There is no stock, but stock is being built + value = `0{% trans "Building" %}: ${row.building}`; + link = '?display=build-orders'; + } else { + // There is no stock + value = `0{% trans "No Stock" %}`; + } } return renderLink(value, `/part/${row.pk}/${link}`); From bc4b66e7d3fee0d96cdeb56195ce3150910b4145 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1lm=C3=A1n=20R=C3=B3zsahegyi?= Date: Sat, 2 Apr 2022 16:01:12 +0200 Subject: [PATCH 14/19] Render partStockLabel based on available/in stock quantites --- InvenTree/templates/js/translated/part.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 16d6062565..1c6bd56d90 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -494,7 +494,11 @@ function duplicateBom(part_id, options={}) { function partStockLabel(part, options={}) { if (part.in_stock) { - return `{% trans "Stock" %}: ${part.in_stock}`; + if (part.unallocated_stock) { + return `{% trans "Available" %}: ${part.unallocated_stock}/${part.in_stock}`; + } else { + return `{% trans "Available" %}: ${part.unallocated_stock}/${part.in_stock}`; + } } else { return `{% trans "No Stock" %}`; } From ade5a81a1ab7f012b0ee85f624a2313b93a03c73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1lm=C3=A1n=20R=C3=B3zsahegyi?= Date: Sat, 2 Apr 2022 16:25:42 +0200 Subject: [PATCH 15/19] Enhance partStockLabel with ordering/building quantites --- InvenTree/templates/js/translated/part.js | 34 ++++++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 1c6bd56d90..1c66c32b6e 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -494,14 +494,40 @@ function duplicateBom(part_id, options={}) { function partStockLabel(part, options={}) { if (part.in_stock) { - if (part.unallocated_stock) { - return `{% trans "Available" %}: ${part.unallocated_stock}/${part.in_stock}`; + // There IS stock available for this part + + // Is stock "low" (below the 'minimum_stock' quantity)? + if (part.minimum_stock && part.minimum_stock > part.in_stock) { + return `{% trans "Low stock" %}: ${part.in_stock}${part.units}`; + } else if (part.unallocated_stock == 0) { + if (part.ordering) { + // There is no available stock, but stock is on order + return `{% trans "On Order" %}: ${part.ordering}${part.units}`; + } else if (part.building) { + // There is no available stock, but stock is being built + return `{% trans "Building" %}: ${part.building}${part.units}`; + } else { + // There is no available stock + return `{% trans "Available" %}: 0/${part.in_stock}${part.units}`; + } } else { - return `{% trans "Available" %}: ${part.unallocated_stock}/${part.in_stock}`; + return `{% trans "Available" %}: ${part.unallocated_stock}/${part.in_stock}${part.units}`; } } else { - return `{% trans "No Stock" %}`; + // There IS NO stock available for this part + + if (part.ordering) { + // There is no stock, but stock is on order + return `{% trans "On Order" %}: ${part.ordering}${part.units}`; + } else if (part.building) { + // There is no stock, but stock is being built + return `{% trans "Building" %}: ${part.building}${part.units}`; + } else { + // There is no stock + return `{% trans "No Stock" %}`; + } } + } From 15253435af8c2ddf2aa99d159b1670cfea0e5ebc Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 5 Apr 2022 00:02:11 +1000 Subject: [PATCH 16/19] Adjustments for partStockLabel function --- InvenTree/templates/js/translated/part.js | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 1c66c32b6e..7a08d534df 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -491,13 +491,16 @@ function duplicateBom(part_id, options={}) { } +/* + * Construct a "badge" label showing stock information for this particular part + */ function partStockLabel(part, options={}) { if (part.in_stock) { // There IS stock available for this part // Is stock "low" (below the 'minimum_stock' quantity)? - if (part.minimum_stock && part.minimum_stock > part.in_stock) { + if ((part.minimum_stock > 0) && (part.minimum_stock > part.in_stock)) { return `{% trans "Low stock" %}: ${part.in_stock}${part.units}`; } else if (part.unallocated_stock == 0) { if (part.ordering) { @@ -507,11 +510,15 @@ function partStockLabel(part, options={}) { // There is no available stock, but stock is being built return `{% trans "Building" %}: ${part.building}${part.units}`; } else { - // There is no available stock - return `{% trans "Available" %}: 0/${part.in_stock}${part.units}`; + // There is no available stock at all + return `{% trans "No stock available" %}`; } - } else { + } else if (part.unallocated_stock < part.in_stock) { + // Unallocated quanttiy is less than total quantity return `{% trans "Available" %}: ${part.unallocated_stock}/${part.in_stock}${part.units}`; + } else { + // Stock is completely available + return `{% trans "Available" %}: ${part.unallocated_stock}${part.units}`; } } else { // There IS NO stock available for this part From 58003bc2cb2211df046881a1cc976148081cf38e Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 5 Apr 2022 00:27:37 +1000 Subject: [PATCH 17/19] Refactor model renderer functions --- InvenTree/templates/js/translated/build.js | 2 +- .../js/translated/model_renderers.js | 56 +++++++++---------- InvenTree/templates/js/translated/search.js | 4 ++ 3 files changed, 32 insertions(+), 30 deletions(-) diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index 201d3c5c9f..d4db965ebd 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -1746,7 +1746,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { required: true, render_part_detail: true, render_location_detail: true, - render_stock_id: false, + render_pk: false, auto_fill: true, auto_fill_filters: auto_fill_filters, onSelect: function(data, field, opts) { diff --git a/InvenTree/templates/js/translated/model_renderers.js b/InvenTree/templates/js/translated/model_renderers.js index 7be6c954c2..8c98fa35de 100644 --- a/InvenTree/templates/js/translated/model_renderers.js +++ b/InvenTree/templates/js/translated/model_renderers.js @@ -31,6 +31,24 @@ */ +// Should the ID be rendered for this string +function renderId(title, pk, parameters={}) { + + // Default = true + var render = true; + + if ('render_pk' in parameters) { + render = parameters['render_pk']; + } + + if (render) { + return `${title}: ${pk}`; + } else { + return ''; + } +} + + // Renderer for "Company" model // eslint-disable-next-line no-unused-vars function renderCompany(name, data, parameters={}, options={}) { @@ -39,7 +57,7 @@ function renderCompany(name, data, parameters={}, options={}) { html += `${data.name} - ${data.description}`; - html += `{% trans "Company ID" %}: ${data.pk}`; + html += renderId('{% trans "Company ID" %}', data.pk, parameters); return html; } @@ -67,18 +85,6 @@ function renderStockItem(name, data, parameters={}, options={}) { part_detail = `${data.part_detail.full_name} - `; } - var render_stock_id = true; - - if ('render_stock_id' in parameters) { - render_stock_id = parameters['render_stock_id']; - } - - var stock_id = ''; - - if (render_stock_id) { - stock_id = `{% trans "Stock ID" %}: ${data.pk}`; - } - var render_location_detail = false; if ('render_location_detail' in parameters) { @@ -88,7 +94,7 @@ function renderStockItem(name, data, parameters={}, options={}) { var location_detail = ''; if (render_location_detail && data.location_detail) { - location_detail = ` - (${data.location_detail.name})`; + location_detail = ` - (${data.location_detail.name})`; } var stock_detail = ''; @@ -103,7 +109,10 @@ function renderStockItem(name, data, parameters={}, options={}) { var html = ` - ${part_detail}${stock_detail}${location_detail}${stock_id} + ${part_detail} + ${stock_detail} + ${location_detail} + ${renderId('{% trans "Stock ID" %}', data.pk, parameters)} `; @@ -183,7 +192,7 @@ function renderPart(name, data, parameters={}, options={}) { ${stock_data} ${extra} - {% trans "Part ID" %}: ${data.pk} + ${renderId('{% trans "Part ID" $}', data.pk, parameters)} `; @@ -245,13 +254,7 @@ function renderPurchaseOrder(name, data, parameters={}, options={}) { html += ` - ${data.description}`; } - html += ` - - - {% trans "Order ID" %}: ${data.pk} - - - `; + html += renderId('{% trans "Order ID" %}', data.pk, parameters); return html; } @@ -277,12 +280,7 @@ function renderSalesOrder(name, data, parameters={}, options={}) { html += ` - ${data.description}`; } - html += ` - - - {% trans "Order ID" %}: ${data.pk} - - `; + html += renderId('{% trans "Order ID" %}', data.pk, parameters); return html; } diff --git a/InvenTree/templates/js/translated/search.js b/InvenTree/templates/js/translated/search.js index 8900007c31..c0c1a07db7 100644 --- a/InvenTree/templates/js/translated/search.js +++ b/InvenTree/templates/js/translated/search.js @@ -132,6 +132,7 @@ function updateSearch() { renderStockItem, { url: '/stock/item', + render_location_detail: true, } ); } @@ -232,6 +233,9 @@ function addSearchQuery(key, title, query_url, query_params, render_func, render query_params.offset = 0; query_params.limit = user_settings.SEARCH_PREVIEW_RESULTS; + // Do not display "pk" value for search results + render_params.render_pk = false; + // Add the result group to the panel $('#offcanvas-search').find('#search-results').append(`
From 0908aa8c8cd5e35abad53ee8d8a052fc65720ce2 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 5 Apr 2022 00:31:44 +1000 Subject: [PATCH 18/19] Allow sorting of part table by unallocated_stock --- InvenTree/part/api.py | 1 + InvenTree/templates/js/translated/part.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index f7bb81520d..e1b0dda61f 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -1348,6 +1348,7 @@ class PartList(generics.ListCreateAPIView): 'creation_date', 'IPN', 'in_stock', + 'unallocated_stock', 'category', ] diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 7a08d534df..d5ab193429 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -1384,7 +1384,7 @@ function loadPartTable(table, url, options={}) { link = '?display=build-orders'; } else { // There is no available stock - value = `0{% trans "Not available" %}`; + value = `0{% trans "No stock available" %}`; } } } else { From c93f9c94f3e007a4511d191438aac1747b911db8 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 5 Apr 2022 00:33:54 +1000 Subject: [PATCH 19/19] Change column title --- InvenTree/templates/js/translated/part.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index d5ab193429..08b258fdc2 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -1362,7 +1362,7 @@ function loadPartTable(table, url, options={}) { col = { field: 'unallocated_stock', - title: '{% trans "Available" %}', + title: '{% trans "Stock" %}', searchable: false, formatter: function(value, row) { var link = '?display=part-stock';