diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 7b003d9406..5777f97afa 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -2,11 +2,14 @@ # InvenTree API version -INVENTREE_API_VERSION = 100 +INVENTREE_API_VERSION = 101 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v101 -> 2023-03-07 : https://github.com/inventree/InvenTree/pull/4462 + - Adds 'total_in_stock' to Part serializer, and supports API ordering + v100 -> 2023-03-04 : https://github.com/inventree/InvenTree/pull/4452 - Adds bulk delete of PurchaseOrderLineItems to API diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 4e9ab8f5ca..5c875a6c0f 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -1337,6 +1337,7 @@ class PartList(APIDownloadMixin, ListCreateAPI): 'creation_date', 'IPN', 'in_stock', + 'total_in_stock', 'unallocated_stock', 'category', 'last_stocktake', diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index a4b4105b6a..55b0d0622c 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -423,7 +423,6 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer): 'full_name', 'image', 'in_stock', - 'variant_stock', 'ordering', 'building', 'IPN', @@ -444,10 +443,12 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer): 'stock_item_count', 'suppliers', 'thumbnail', + 'total_in_stock', 'trackable', 'unallocated_stock', 'units', 'variant_of', + 'variant_stock', 'virtual', 'pricing_min', 'pricing_max', @@ -554,11 +555,20 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer): allocated_to_build_orders=part.filters.annotate_build_order_allocations(), ) + # Annotate the queryset with the 'total_in_stock' quantity + # This is the 'in_stock' quantity summed with the 'variant_stock' quantity + queryset = queryset.annotate( + total_in_stock=ExpressionWrapper( + F('in_stock') + F('variant_stock'), + 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'), + F('total_in_stock') - F('allocated_to_sales_orders') - F('allocated_to_build_orders'), output_field=models.DecimalField(), ) ) @@ -579,6 +589,7 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer): building = serializers.FloatField(read_only=True) in_stock = serializers.FloatField(read_only=True) variant_stock = serializers.FloatField(read_only=True) + total_in_stock = serializers.FloatField(read_only=True) ordering = serializers.FloatField(read_only=True) stock_item_count = serializers.IntegerField(read_only=True) suppliers = serializers.IntegerField(read_only=True) diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index e5c9d6f07d..91932e0545 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -215,7 +215,7 @@ {% endif %} {% if part.component %} - {% if required_build_order_quantity > 0 %} + {% if required_build_order_quantity > 0 or allocated_build_order_quantity > 0 %} {% trans "Allocated to Build Orders" %} @@ -224,7 +224,7 @@ {% endif %} {% endif %} {% if part.salable %} - {% if required_sales_order_quantity > 0 %} + {% if required_sales_order_quantity > 0 or allocated_sales_order_quantity > 0 %} {% trans "Allocated to Sales Orders" %} diff --git a/InvenTree/part/templatetags/inventree_extras.py b/InvenTree/part/templatetags/inventree_extras.py index 9e5981aa09..c658267104 100644 --- a/InvenTree/part/templatetags/inventree_extras.py +++ b/InvenTree/part/templatetags/inventree_extras.py @@ -404,7 +404,10 @@ def progress_bar(val, max_val, *args, **kwargs): else: style = '' - percent = float(val / max_val) * 100 + if max_val != 0: + percent = float(val / max_val) * 100 + else: + percent = 0 if percent > 100: percent = 100 diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 27b0545e2e..db75386118 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -589,68 +589,85 @@ function partStockLabel(part, options={}) { // Prevent literal string 'null' from being displayed var units = part.units || ''; - var text = ''; + let elements = []; // Check for stock - if (part.in_stock) { + if (part.total_in_stock) { // There IS stock available for this part // Is stock "low" (below the 'minimum_stock' quantity)? - if ((part.minimum_stock > 0) && (part.minimum_stock > part.in_stock)) { - text += `{% trans "Low stock" %}: ${part.in_stock}`; - } else if (part.unallocated_stock == 0) { + if ((part.minimum_stock > 0) && (part.minimum_stock > part.total_in_stock)) { + elements.push(`{% trans "Low stock" %}: ${part.total_in_stock}`); + } else if (part.unallocated_stock <= 0) { // There is no available stock at all - text += `{% trans "No stock available" %}`; + elements.push(`{% trans "No stock available" %}`); } else if (part.unallocated_stock < part.in_stock) { - // Unallocated quanttiy is less than total quantity - text += `{% trans "Available" %}: ${part.unallocated_stock}/${part.in_stock}`; + // Unallocated quantity is less than total quantity + if (options.hideTotalStock) { + elements.push(`{% trans "Available" %}: ${part.unallocated_stock}`); + } else { + elements.push(`{% trans "Available" %}: ${part.unallocated_stock}/${part.in_stock}`); + } } else { // Stock is completely available - text += `{% trans "Available" %}: ${part.unallocated_stock}`; + if (!options.hideTotalStock) { + elements.push(`{% trans "Available" %}: ${part.unallocated_stock}`); + } } } else { // There IS NO stock available for this part - text += `{% trans "No Stock" %}`; + elements.push(`{% trans "No Stock" %}`); } // Check for items on order if (part.ordering) { - text += ` | {% trans "On Order" %}: ${part.ordering}`; + elements.push(`{% trans "On Order" %}: ${part.ordering}`); } // Check for items beeing built if (part.building) { - text += ` | {% trans "Building" %}: ${part.building}`; - } - - // Check for demand from unallocated build orders - var required_build_order_quantity = null; - var required_sales_order_quantity = null; - inventreeGet(`/api/part/${part.pk}/requirements/`, {}, { - async: false, - success: function(response) { - required_build_order_quantity = 0; - if (response.required_build_order_quantity) { - required_build_order_quantity = response.required_build_order_quantity; - } - required_sales_order_quantity = 0; - if (response.required_sales_order_quantity) { - required_sales_order_quantity = response.required_sales_order_quantity; - } - } - }); - if ((required_build_order_quantity == null) || (required_sales_order_quantity == null)) { - console.error(`Error loading part requirements for part ${part.pk}`); - return; - } - var demand = (required_build_order_quantity - part.allocated_to_build_orders) + (required_sales_order_quantity - part.allocated_to_sales_orders); - if (demand) { - text += ` | {% trans "Demand" %}: ${demand}`; + elements.push(`{% trans "Building" %}: ${part.building}`); } // Determine badge color based on overall stock health - var stock_health = part.in_stock + part.building + part.ordering - part.minimum_stock - required_build_order_quantity - required_sales_order_quantity; + var stock_health = part.unallocated_stock + part.building + part.ordering - part.minimum_stock; + + // TODO: Refactor the API to include this information, so we don't have to request it! + if (!options.noDemandInfo) { + + // Check for demand from unallocated build orders + var required_build_order_quantity = null; + var required_sales_order_quantity = null; + + inventreeGet(`/api/part/${part.pk}/requirements/`, {}, { + async: false, + success: function(response) { + required_build_order_quantity = 0; + if (response.required_build_order_quantity) { + required_build_order_quantity = response.required_build_order_quantity; + } + required_sales_order_quantity = 0; + if (response.required_sales_order_quantity) { + required_sales_order_quantity = response.required_sales_order_quantity; + } + } + }); + + if ((required_build_order_quantity == null) || (required_sales_order_quantity == null)) { + console.error(`Error loading part requirements for part ${part.pk}`); + return; + } + + var demand = (required_build_order_quantity - part.allocated_to_build_orders) + (required_sales_order_quantity - part.allocated_to_sales_orders); + if (demand) { + elements.push(`{% trans "Demand" %}: ${demand}`); + } + + stock_health -= (required_build_order_quantity + required_sales_order_quantity); + } + var bg_class = ''; + if (stock_health < 0) { // Unsatisfied demand and/or below minimum stock bg_class = 'bg-danger'; @@ -662,14 +679,19 @@ function partStockLabel(part, options={}) { bg_class = 'bg-success'; } - // show units next to stock badge - var unit_badge = ''; + // Display units next to stock badge + let unit_badge = ''; + if (units && !options.no_units) { unit_badge = `{% trans "Unit" %}: ${units} `; } - // return badge html - return `${unit_badge}${text}`; + if (elements.length > 0) { + let text = elements.join(' | '); + return `${unit_badge}${text}`; + } else { + return ''; + } } @@ -1150,24 +1172,25 @@ function loadPartVariantTable(table, partId, options={}) { title: '{% trans "Description" %}', }, { - field: 'in_stock', + field: 'total_in_stock', title: '{% trans "Stock" %}', sortable: true, formatter: function(value, row) { - var base_stock = row.in_stock; - var variant_stock = row.variant_stock || 0; + var text = renderLink(value, `/part/${row.pk}/?display=part-stock`); - var total = base_stock + variant_stock; + text += partStockLabel(row, { + noDemandInfo: true, + hideTotalStock: true, + classes: 'float-right', + }); - var text = `${total}`; - - if (variant_stock > 0) { + if (row.variant_stock > 0) { text = `${text}`; text += ``; } - return renderLink(text, `/part/${row.pk}/?display=part-stock`); + return text; } }, { @@ -1815,8 +1838,6 @@ function loadPartTable(table, url, options={}) { var filters = {}; - var col = null; - if (!options.disableFilters) { filters = loadTableFilters('parts'); } @@ -1884,10 +1905,11 @@ function loadPartTable(table, url, options={}) { } }); - col = { + columns.push({ sortName: 'category', field: 'category_detail', title: '{% trans "Category" %}', + sortable: true, formatter: function(value, row) { if (row.category && row.category_detail) { var text = shortenString(row.category_detail.pathstring); @@ -1896,81 +1918,26 @@ function loadPartTable(table, url, options={}) { return '{% trans "No category" %}'; } } - }; + }); - if (!options.params.ordering) { - col['sortable'] = true; - } - columns.push(col); - - col = { - field: 'unallocated_stock', + columns.push({ + field: 'total_in_stock', title: '{% trans "Stock" %}', - searchable: false, + sortable: true, formatter: function(value, row) { - var text = ''; + var text = renderLink(value, `/part/${row.pk}/?display=part-stock`); - var total_stock = row.in_stock; - - if (row.variant_stock) { - total_stock += row.variant_stock; - } - - var text = `${total_stock}`; - - // Construct extra informational badges - var badges = ''; - - if (total_stock == 0) { - badges += ``; - } else if (total_stock < row.minimum_stock) { - badges += ``; - } - - if (row.ordering && row.ordering > 0) { - badges += renderLink( - ``, - `/part/${row.pk}/?display=purchase-orders` - ); - } - - if (row.building && row.building > 0) { - badges += renderLink( - ``, - `/part/${row.pk}/?display=build-orders` - ); - } - - if (row.variant_stock && row.variant_stock > 0) { - badges += ``; - } - - if (row.allocated_to_build_orders > 0) { - badges += ``; - } - - if (row.allocated_to_sales_orders > 0) { - badges += ``; - } - - if (row.units) { - text += ` ${row.units}`; - } - - text = renderLink(text, `/part/${row.pk}/?display=part-stock`); - text += badges; + text += partStockLabel(row, { + noDemandInfo: true, + hideTotalStock: true, + classes: 'float-right', + }); return text; } - }; - - if (!options.params.ordering) { - col['sortable'] = true; - } - - columns.push(col); + }); // Pricing information columns.push({ @@ -1985,6 +1952,7 @@ function loadPartTable(table, url, options={}) { } }); + // External link / URL columns.push({ field: 'link', title: '{% trans "Link" %}',