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) 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/part/models.py b/InvenTree/part/models.py index fad0f12e28..c493028d71 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 @@ -1433,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/serializers.py b/InvenTree/part/serializers.py index c46950adca..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 _ @@ -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,51 @@ 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__shipment_date=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(), + ) + ) + + """ + 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(), + ) + ) + + # 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): @@ -376,9 +424,12 @@ 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) + 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) - building = serializers.FloatField(read_only=True) stock_item_count = serializers.IntegerField(read_only=True) suppliers = serializers.IntegerField(read_only=True) @@ -399,7 +450,8 @@ class PartSerializer(InvenTreeModelSerializer): partial = True fields = [ 'active', - + 'allocated_to_build_orders', + 'allocated_to_sales_orders', 'assembly', 'category', 'category_detail', @@ -430,6 +482,7 @@ class PartSerializer(InvenTreeModelSerializer): 'suppliers', 'thumbnail', 'trackable', + 'unallocated_stock', 'units', 'variant_of', 'virtual', diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 23f929bca0..bea7154612 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): """ @@ -247,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') @@ -815,6 +818,10 @@ class PartAPIAggregationTest(InvenTreeAPITestCase): 'location', 'bom', 'test_templates', + 'build', + 'location', + 'stock', + 'sales_order', ] roles = [ @@ -826,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', @@ -880,6 +890,153 @@ 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}) + + part = Part.objects.get(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) + + # 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', + 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) + + # 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, + 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) + + # 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/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/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 diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index 81973aed31..73bee54110 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,13 +136,13 @@ 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) + self.assertEqual(len(response), 18) def test_filter_by_depleted(self): """ @@ -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']) @@ -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): """ @@ -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,11 +309,11 @@ 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}) - 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)