From d535e4fa12f4274396f9dce7071c4cae9e40ba31 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 15 Apr 2022 22:38:31 +1000 Subject: [PATCH 01/21] Add 'available_variant_stock' to BomItem serializer - Note: This is definitely *not* the optimum solution here --- InvenTree/part/models.py | 55 +++++++++++++++++++++++++++++++++++ InvenTree/part/serializers.py | 7 +++++ 2 files changed, 62 insertions(+) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index b7269f3e5e..3635f22587 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -2703,6 +2703,61 @@ class BomItem(models.Model, DataImportMixin): def get_api_url(): return reverse('api-bom-list') + def available_variant_stock(self): + """ + Returns the total quantity of variant stock available for this BomItem. + + Notes: + - If "allow_variants" is False, this will return zero + - This is used for the API serializer, and is very inefficient + - This logic needs to be converted to a queryset annotation + """ + + # Variant stock is not allowed for this BOM item + if not self.allow_variants: + return 0 + + # Extract a flattened list of part variants + variants = self.sub_part.get_descendants(include_self=False) + + # Calculate 'in_stock' quantity - this is the total current stock count + query = StockModels.StockItem.objects.filter(StockModels.StockItem.IN_STOCK_FILTER) + + query = query.filter( + part__in=variants, + ) + + query = query.aggregate( + in_stock=Coalesce(Sum('quantity'), Decimal(0)) + ) + + in_stock = query['in_stock'] or 0 + + # Calculate the quantity allocated to sales orders + query = OrderModels.SalesOrderAllocation.objects.filter( + line__order__status__in=SalesOrderStatus.OPEN, + shipment__shipment_date=None, + item__part__in=variants, + ).aggregate( + allocated=Coalesce(Sum('quantity'), Decimal(0)), + ) + + sales_order_allocations = query['allocated'] or 0 + + # Calculate the quantity allocated to build orders + query = BuildModels.BuildItem.objects.filter( + build__status__in=BuildStatus.ACTIVE_CODES, + stock_item__part__in=variants, + ).aggregate( + allocated=Coalesce(Sum('quantity'), Decimal(0)), + ) + + build_order_allocations = query['allocated'] or 0 + + available = in_stock - sales_order_allocations - build_order_allocations + + return max(available, 0) + def get_valid_parts_for_allocation(self, allow_variants=True, allow_substitutes=True): """ Return a list of valid parts which can be allocated against this BomItem: diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 8bf3d77501..0daac6e630 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -581,6 +581,12 @@ class BomItemSerializer(InvenTreeModelSerializer): available_stock = serializers.FloatField(read_only=True) available_substitute_stock = serializers.FloatField(read_only=True) + # Note: 2022-04-15 + # The 'available_variant_stock' field is calculated per-object, + # which means it is very inefficient! + # TODO: This needs to be converted into a query annotation, if possible! + available_variant_stock = serializers.FloatField(read_only=True) + def __init__(self, *args, **kwargs): # part_detail and sub_part_detail serializers are only included if requested. # This saves a bunch of database requests @@ -790,6 +796,7 @@ class BomItemSerializer(InvenTreeModelSerializer): # Annotated fields describing available quantity 'available_stock', 'available_substitute_stock', + 'available_variant_stock', ] From 7f07c526896bd6329dcc0e8253b48cfc33d798e9 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 15 Apr 2022 22:40:24 +1000 Subject: [PATCH 02/21] Incremement APi version --- InvenTree/InvenTree/version.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index 32f359f5f2..160716bba3 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 = 38 +INVENTREE_API_VERSION = 39 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v39 -> 2022-04-15 : https://github.com/inventree/InvenTree/pull/2833 + - Adds 'available_variant_stock' information to the BomItem API + v38 -> 2022-04-14 : https://github.com/inventree/InvenTree/pull/2828 - Adds the ability to include stock test results for "installed items" From 6f5b560fb61d89ff6071da7057be052a3c8fa561 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 15 Apr 2022 22:45:22 +1000 Subject: [PATCH 03/21] Update BOM table to include variant stock information --- InvenTree/templates/js/translated/bom.js | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js index 6591068e9d..7ae922788f 100644 --- a/InvenTree/templates/js/translated/bom.js +++ b/InvenTree/templates/js/translated/bom.js @@ -807,15 +807,27 @@ function loadBomTable(table, options={}) { var url = `/part/${row.sub_part_detail.pk}/?display=part-stock`; // Calculate total "available" (unallocated) quantity - var total = row.available_stock + row.available_substitute_stock; + var total = row.available_stock; + + total += (row.available_substitute_stock || 0); + total += (row.available_variant_stock || 0); var text = `${total}`; if (total <= 0) { text = `{% trans "No Stock Available" %}`; } else { - if (row.available_substitute_stock > 0) { - text += ``; + var extra = ''; + if (row.available_substitute_stock && row.available_variant_stock) { + extra = '{% trans "Includes variant and substitute stock" %}'; + } else if (row.available_variant_stock) { + extra = '{% trans "Includes variant stock" %}'; + } else if (row.available_substitute_stock) { + extra = '{% trans "Includes substitute stock" %}'; + } + + if (extra) { + text += ``; } } @@ -910,7 +922,7 @@ function loadBomTable(table, options={}) { formatter: function(value, row) { var can_build = 0; - var available = row.available_stock + row.available_substitute_stock; + var available = row.available_stock + (row.available_substitute_stock || 0) + (row.available_variant_stock || 0); if (row.quantity > 0) { can_build = available / row.quantity; From 43135036491991c1226da03af5b53ff15b13904f Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 15 Apr 2022 22:47:32 +1000 Subject: [PATCH 04/21] PEP fix --- InvenTree/part/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 3635f22587..01ea8c661b 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -2716,7 +2716,7 @@ class BomItem(models.Model, DataImportMixin): # Variant stock is not allowed for this BOM item if not self.allow_variants: return 0 - + # Extract a flattened list of part variants variants = self.sub_part.get_descendants(include_self=False) From 1d73f7c066615a1cf0a7fc03271114a995d61903 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 21 Apr 2022 20:14:46 +1000 Subject: [PATCH 05/21] Update API version info --- InvenTree/InvenTree/api_version.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 7851efd8dd..3f86d8a669 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -4,11 +4,14 @@ InvenTree API version information # InvenTree API version -INVENTREE_API_VERSION = 40 +INVENTREE_API_VERSION = 41 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v41 -> 2022-04-21 : https://github.com/inventree/InvenTree/pull/2833 + - Adds variant stock information to the Part and BomItem serializers + v40 -> 2022-04-19 - Adds ability to filter StockItem list by "tracked" parameter - This checks the serial number or batch code fields From fa2510c42f284e0c0caa51bbe9890c61b22a01bb Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 21 Apr 2022 23:51:29 +1000 Subject: [PATCH 06/21] Use a proper queryset annotation to calculate the "available_variant_stock" --- InvenTree/part/models.py | 54 ------------------------------ InvenTree/part/serializers.py | 62 ++++++++++++++++++++++++++++++----- 2 files changed, 54 insertions(+), 62 deletions(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 01ea8c661b..d942c68c85 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -2703,60 +2703,6 @@ class BomItem(models.Model, DataImportMixin): def get_api_url(): return reverse('api-bom-list') - def available_variant_stock(self): - """ - Returns the total quantity of variant stock available for this BomItem. - - Notes: - - If "allow_variants" is False, this will return zero - - This is used for the API serializer, and is very inefficient - - This logic needs to be converted to a queryset annotation - """ - - # Variant stock is not allowed for this BOM item - if not self.allow_variants: - return 0 - - # Extract a flattened list of part variants - variants = self.sub_part.get_descendants(include_self=False) - - # Calculate 'in_stock' quantity - this is the total current stock count - query = StockModels.StockItem.objects.filter(StockModels.StockItem.IN_STOCK_FILTER) - - query = query.filter( - part__in=variants, - ) - - query = query.aggregate( - in_stock=Coalesce(Sum('quantity'), Decimal(0)) - ) - - in_stock = query['in_stock'] or 0 - - # Calculate the quantity allocated to sales orders - query = OrderModels.SalesOrderAllocation.objects.filter( - line__order__status__in=SalesOrderStatus.OPEN, - shipment__shipment_date=None, - item__part__in=variants, - ).aggregate( - allocated=Coalesce(Sum('quantity'), Decimal(0)), - ) - - sales_order_allocations = query['allocated'] or 0 - - # Calculate the quantity allocated to build orders - query = BuildModels.BuildItem.objects.filter( - build__status__in=BuildStatus.ACTIVE_CODES, - stock_item__part__in=variants, - ).aggregate( - allocated=Coalesce(Sum('quantity'), Decimal(0)), - ) - - build_order_allocations = query['allocated'] or 0 - - available = in_stock - sales_order_allocations - build_order_allocations - - return max(available, 0) def get_valid_parts_for_allocation(self, allow_variants=True, allow_substitutes=True): """ diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 0daac6e630..6dc502ae85 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -7,7 +7,9 @@ from decimal import Decimal from django.urls import reverse_lazy from django.db import models, transaction -from django.db.models import ExpressionWrapper, F, Q +from django.db.models import ExpressionWrapper, F, Q, Func +from django.db.models import Subquery, OuterRef, IntegerField, FloatField + from django.db.models.functions import Coalesce from django.utils.translation import ugettext_lazy as _ @@ -577,14 +579,9 @@ class BomItemSerializer(InvenTreeModelSerializer): purchase_price_range = serializers.SerializerMethodField() - # Annotated fields + # Annotated fields for available stock available_stock = serializers.FloatField(read_only=True) available_substitute_stock = serializers.FloatField(read_only=True) - - # Note: 2022-04-15 - # The 'available_variant_stock' field is calculated per-object, - # which means it is very inefficient! - # TODO: This needs to be converted into a query annotation, if possible! available_variant_stock = serializers.FloatField(read_only=True) def __init__(self, *args, **kwargs): @@ -619,11 +616,18 @@ class BomItemSerializer(InvenTreeModelSerializer): queryset = queryset.prefetch_related('sub_part') queryset = queryset.prefetch_related('sub_part__category') + queryset = queryset.prefetch_related( 'sub_part__stock_items', 'sub_part__stock_items__allocations', 'sub_part__stock_items__sales_order_allocations', ) + + queryset = queryset.prefetch_related( + 'substitutes', + 'substitutes__part__stock_items', + ) + queryset = queryset.prefetch_related('sub_part__supplier_parts__pricebreaks') return queryset @@ -713,7 +717,7 @@ class BomItemSerializer(InvenTreeModelSerializer): ), ) - # Calculate 'available_variant_stock' field + # Calculate 'available_substitute_stock' field queryset = queryset.annotate( available_substitute_stock=ExpressionWrapper( F('substitute_stock') - F('substitute_build_allocations') - F('substitute_sales_allocations'), @@ -721,6 +725,47 @@ class BomItemSerializer(InvenTreeModelSerializer): ) ) + # Annotate the queryset with 'available variant stock' information + variant_stock_query = StockItem.objects.filter( + part__tree_id=OuterRef('sub_part__tree_id'), + part__lft__gt=OuterRef('sub_part__lft'), + part__rght__lt=OuterRef('sub_part__rght'), + ) + + queryset = queryset.alias( + variant_stock_total=Coalesce( + Subquery( + variant_stock_query.annotate( + total=Func(F('quantity'), function='SUM', output_field=FloatField()) + ).values('total')), + 0, + output_field=FloatField() + ), + variant_stock_build_order_allocations=Coalesce( + Subquery( + variant_stock_query.annotate( + total=Func(F('sales_order_allocations__quantity'), function='SUM', output_field=FloatField()), + ).values('total')), + 0, + output_field=FloatField(), + ), + variant_stock_sales_order_allocations=Coalesce( + Subquery( + variant_stock_query.annotate( + total=Func(F('allocations__quantity'), function='SUM', output_field=FloatField()), + ).values('total')), + 0, + output_field=FloatField(), + ) + ) + + queryset = queryset.annotate( + available_variant_stock=ExpressionWrapper( + F('variant_stock_total') - F('variant_stock_build_order_allocations') - F('variant_stock_sales_order_allocations'), + output_field=FloatField(), + ) + ) + return queryset def get_purchase_price_range(self, obj): @@ -797,6 +842,7 @@ class BomItemSerializer(InvenTreeModelSerializer): 'available_stock', 'available_substitute_stock', 'available_variant_stock', + ] From ec34d23bfc36dbe60a46e6d05435a4a19fc7b5ea Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 21 Apr 2022 23:58:12 +1000 Subject: [PATCH 07/21] Update javascript rendering in BOM table --- InvenTree/templates/js/translated/bom.js | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js index 7ae922788f..0d27a5e028 100644 --- a/InvenTree/templates/js/translated/bom.js +++ b/InvenTree/templates/js/translated/bom.js @@ -807,22 +807,23 @@ function loadBomTable(table, options={}) { var url = `/part/${row.sub_part_detail.pk}/?display=part-stock`; // Calculate total "available" (unallocated) quantity - var total = row.available_stock; + var base_stock = row.available_stock; + var substitute_stock = row.available_substitute_stock || 0; + var variant_stock = row.allow_variants ? (row.available_variant_stock || 0) : 0; + + var available_stock = base_stock + substitute_stock + variant_stock; - total += (row.available_substitute_stock || 0); - total += (row.available_variant_stock || 0); + var text = `${available_stock}`; - var text = `${total}`; - - if (total <= 0) { + if (available_stock <= 0) { text = `{% trans "No Stock Available" %}`; } else { var extra = ''; - if (row.available_substitute_stock && row.available_variant_stock) { + if ((substitute_stock > 0) && (variant_stock > 0)) { extra = '{% trans "Includes variant and substitute stock" %}'; - } else if (row.available_variant_stock) { + } else if (variant_stock > 0) { extra = '{% trans "Includes variant stock" %}'; - } else if (row.available_substitute_stock) { + } else if (substitute_stock > 0) { extra = '{% trans "Includes substitute stock" %}'; } From aa20d84cc1f89beabe04b3385be4dfd2a3fdb5e3 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 22 Apr 2022 00:00:50 +1000 Subject: [PATCH 08/21] Update javascript rendering in build table --- InvenTree/templates/js/translated/build.js | 31 +++++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index ff1f475d5d..eb955d7ff0 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -1425,19 +1425,36 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { title: '{% trans "Available" %}', sortable: true, formatter: function(value, row) { - var total = row.available_stock + row.available_substitute_stock; - var text = `${total}`; + var url = `/part/${row.sub_part_detail.pk}/?display=part-stock`; - if (total <= 0) { + // Calculate total "available" (unallocated) quantity + var base_stock = row.available_stock; + var substitute_stock = row.available_substitute_stock || 0; + var variant_stock = row.allow_variants ? (row.available_variant_stock || 0) : 0; + + var available_stock = base_stock + substitute_stock + variant_stock; + + var text = `${available_stock}`; + + if (available_stock <= 0) { text = `{% trans "No Stock Available" %}`; } else { - if (row.available_substitute_stock > 0) { - text += ``; + var extra = ''; + if ((substitute_stock > 0) && (variant_stock > 0)) { + extra = '{% trans "Includes variant and substitute stock" %}'; + } else if (variant_stock > 0) { + extra = '{% trans "Includes variant stock" %}'; + } else if (substitute_stock > 0) { + extra = '{% trans "Includes substitute stock" %}'; + } + + if (extra) { + text += ``; } } - - return text; + + return renderLink(text, url); } }, { From 8b9aa86a0a57889ac8ada9a883364a2e692068de Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 22 Apr 2022 00:14:37 +1000 Subject: [PATCH 09/21] Add 'variant_stock' to Part API serializer --- InvenTree/part/serializers.py | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 6dc502ae85..387d3df1d5 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -8,7 +8,7 @@ from decimal import Decimal from django.urls import reverse_lazy from django.db import models, transaction from django.db.models import ExpressionWrapper, F, Q, Func -from django.db.models import Subquery, OuterRef, IntegerField, FloatField +from django.db.models import Subquery, OuterRef, FloatField from django.db.models.functions import Coalesce from django.utils.translation import ugettext_lazy as _ @@ -310,9 +310,6 @@ class PartSerializer(InvenTreeModelSerializer): to reduce database trips. """ - # TODO: Update the "in_stock" annotation to include stock for variants of the part - # Ref: https://github.com/inventree/InvenTree/issues/2240 - # Annotate with the total 'in stock' quantity queryset = queryset.annotate( in_stock=Coalesce( @@ -327,6 +324,24 @@ class PartSerializer(InvenTreeModelSerializer): stock_item_count=SubqueryCount('stock_items') ) + # Annotate with the total variant stock quantity + variant_query = StockItem.objects.filter( + part__tree_id=OuterRef('tree_id'), + part__lft__gt=OuterRef('lft'), + part__rght__lt=OuterRef('rght'), + ).filter(StockItem.IN_STOCK_FILTER) + + queryset = queryset.annotate( + variant_stock=Coalesce( + Subquery( + variant_query.annotate( + total=Func(F('quantity'), function='SUM', output_field=FloatField()) + ).values('total')), + 0, + output_field=FloatField(), + ) + ) + # Filter to limit builds to "active" build_filter = Q( status__in=BuildStatus.ACTIVE_CODES @@ -431,6 +446,7 @@ class PartSerializer(InvenTreeModelSerializer): unallocated_stock = serializers.FloatField(read_only=True) building = serializers.FloatField(read_only=True) in_stock = serializers.FloatField(read_only=True) + variant_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) @@ -465,6 +481,7 @@ class PartSerializer(InvenTreeModelSerializer): 'full_name', 'image', 'in_stock', + 'variant_stock', 'ordering', 'building', 'IPN', @@ -730,7 +747,7 @@ class BomItemSerializer(InvenTreeModelSerializer): part__tree_id=OuterRef('sub_part__tree_id'), part__lft__gt=OuterRef('sub_part__lft'), part__rght__lt=OuterRef('sub_part__rght'), - ) + ).filter(StockItem.IN_STOCK_FILTER) queryset = queryset.alias( variant_stock_total=Coalesce( From f504dfe7972183ebc3e59f5b2b18b2f861dd6b6b Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 22 Apr 2022 00:20:32 +1000 Subject: [PATCH 10/21] part variant table now displays information about variant stock quantities --- InvenTree/templates/js/translated/part.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 1a8e728040..35d5d0d5a6 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -672,7 +672,20 @@ function loadPartVariantTable(table, partId, options={}) { field: 'in_stock', title: '{% trans "Stock" %}', formatter: function(value, row) { - return renderLink(value, `/part/${row.pk}/?display=part-stock`); + + var base_stock = row.in_stock; + var variant_stock = row.variant_stock || 0; + + var total = base_stock + variant_stock; + + var text = `${total}`; + + if (variant_stock > 0) { + text = `${text}`; + text += ``; + } + + return renderLink(text, `/part/${row.pk}/?display=part-stock`); } } ]; From 6dec8ba1132b0ddafb5c234de5be6f630c9788cd Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 22 Apr 2022 00:24:25 +1000 Subject: [PATCH 11/21] PEP fix --- InvenTree/part/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index d942c68c85..b7269f3e5e 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -2703,7 +2703,6 @@ class BomItem(models.Model, DataImportMixin): def get_api_url(): return reverse('api-bom-list') - def get_valid_parts_for_allocation(self, allow_variants=True, allow_substitutes=True): """ Return a list of valid parts which can be allocated against this BomItem: From 85fd2478788e85256447806097510e61dba26250 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 26 Apr 2022 15:54:03 +1000 Subject: [PATCH 12/21] Fix for 'auto-allocate' stock to build orders - Allocation of serialized stock items would cause issue - Exclude serialized stock from auto allocation process --- InvenTree/build/models.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 9ba348b0b4..e5189e6073 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -871,6 +871,9 @@ class Build(MPTTModel, ReferenceIndexingMixin): part__in=[p for p in available_parts], ) + # Filter out "serialized" stock items, these cannot be auto-allocated + available_stock = available_stock.filter(Q(serial=None) | Q(serial='')) + if location: # Filter only stock items located "below" the specified location sublocations = location.get_descendants(include_self=True) From 7aaa4a581231e6ff143c22802c76baf3c5f76ffd Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 26 Apr 2022 16:39:03 +1000 Subject: [PATCH 13/21] add unit tests for new variant_stock annotations --- InvenTree/part/test_api.py | 88 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index ddfaa44e94..08b06a0f36 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -658,6 +658,94 @@ class PartAPITest(InvenTreeAPITestCase): self.assertEqual(len(response.data), 101) + def test_variant_stock(self): + """ + Unit tests for the 'variant_stock' annotation, + which provides a stock count for *variant* parts + """ + + # Ensure the MPTT structure is in a known state before running tests + Part.objects.rebuild() + + # Initially, there are no "chairs" in stock, + # so each 'chair' template should report variant_stock=0 + url = reverse('api-part-list') + + # Look at the "detail" URL for the master chair template + response = self.get('/api/part/10000/', {}, expected_code=200) + + # This part should report 'zero' as variant stock + self.assertEqual(response.data['variant_stock'], 0) + + # Grab a list of all variant chairs *under* the master template + response = self.get( + url, + { + 'ancestor': 10000, + }, + expected_code=200, + ) + + # 4 total descendants + self.assertEqual(len(response.data), 4) + + for variant in response.data: + self.assertEqual(variant['variant_stock'], 0) + + # Now, let's make some variant stock + for variant in Part.objects.get(pk=10000).get_descendants(include_self=False): + StockItem.objects.create( + part=variant, + quantity=100, + ) + + response = self.get('/api/part/10000/', {}, expected_code=200) + + self.assertEqual(response.data['in_stock'], 0) + self.assertEqual(response.data['variant_stock'], 400) + + # Check that each variant reports the correct stock quantities + response = self.get( + url, + { + 'ancestor': 10000, + }, + expected_code=200, + ) + + expected_variant_stock = { + 10001: 0, + 10002: 0, + 10003: 100, + 10004: 0, + } + + for variant in response.data: + self.assertEqual(variant['in_stock'], 100) + self.assertEqual(variant['variant_stock'], expected_variant_stock[variant['pk']]) + + # Add some 'sub variants' for the green chair variant + green_chair = Part.objects.get(pk=10004) + + for i in range(10): + gcv = Part.objects.create( + name=f"GC Var {i}", + description="Green chair variant", + variant_of=green_chair, + ) + + StockItem.objects.create( + part=gcv, + quantity=50, + ) + + # Spot check of some values + response = self.get('/api/part/10000/', {}) + self.assertEqual(response.data['variant_stock'], 900) + + response = self.get('/api/part/10004/', {}) + self.assertEqual(response.data['variant_stock'], 500) + class PartDetailTests(InvenTreeAPITestCase): """ From ee47be4c9ed5999a115d808b6a945a5c7cbb83f4 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 26 Apr 2022 17:05:21 +1000 Subject: [PATCH 14/21] Add unit test for 'available_variant_stock' in BomItem API serializer --- InvenTree/part/fixtures/bom.yaml | 1 + InvenTree/part/test_api.py | 40 +++++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/InvenTree/part/fixtures/bom.yaml b/InvenTree/part/fixtures/bom.yaml index facb7e76ae..ac52452d75 100644 --- a/InvenTree/part/fixtures/bom.yaml +++ b/InvenTree/part/fixtures/bom.yaml @@ -7,6 +7,7 @@ part: 100 sub_part: 1 quantity: 10 + allow_variants: True # 40 x R_2K2_0805 - model: part.bomitem diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 08b06a0f36..f0770eb1f5 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -698,7 +698,7 @@ class PartAPITest(InvenTreeAPITestCase): part=variant, quantity=100, ) - + response = self.get('/api/part/10000/', {}, expected_code=200) self.assertEqual(response.data['in_stock'], 0) @@ -1629,6 +1629,44 @@ class BomItemTest(InvenTreeAPITestCase): self.assertEqual(len(response.data), i) + def test_bom_variant_stock(self): + """ + Test for 'available_variant_stock' annotation + """ + + Part.objects.rebuild() + + # BOM item we are interested in + bom_item = BomItem.objects.get(pk=1) + + response = self.get('/api/bom/1/', {}, expected_code=200) + + # Initially, no variant stock available + self.assertEqual(response.data['available_variant_stock'], 0) + + # Create some 'variants' of the referenced sub_part + bom_item.sub_part.is_template = True + bom_item.sub_part.save() + + for i in range(10): + # Create a variant part + vp = Part.objects.create( + name=f"Var {i}", + description="Variant part", + variant_of=bom_item.sub_part, + ) + + # Create a stock item + StockItem.objects.create( + part=vp, + quantity=100, + ) + + # There should now be variant stock available + response = self.get('/api/bom/1/', {}, expected_code=200) + + self.assertEqual(response.data['available_variant_stock'], 1000) + class PartParameterTest(InvenTreeAPITestCase): """ From 3ef0386593e49250f99cc3f0b4fe0ceff5a9b1a5 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 26 Apr 2022 17:14:47 +1000 Subject: [PATCH 15/21] Prevent inactive or trackable parts from being 'auto allocated' --- InvenTree/part/models.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index d6ca9f650c..1edae69351 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -2732,7 +2732,21 @@ class BomItem(models.Model, DataImportMixin): for sub in self.substitutes.all(): parts.add(sub.part) - return parts + valid_parts = [] + + for p in parts: + + # Inactive parts cannot be 'auto allocated' + if not p.active: + continue + + # Trackable parts cannot be 'auto allocated' + if p.trackable: + continue + + valid_parts.append(p) + + return valid_parts def is_stock_item_valid(self, stock_item): """ From cb12222e17a11524583e4d74f09032bcfea24876 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 26 Apr 2022 19:23:41 +1000 Subject: [PATCH 16/21] Re-enable the "pricing" tab - Previously was gated entirely on the "PART_SHOW_PRICE_HISTORY" setting --- InvenTree/part/templates/part/detail.html | 3 +-- InvenTree/part/templates/part/part_sidebar.html | 3 +-- InvenTree/part/templates/part/prices.html | 11 ++++++++--- InvenTree/part/views.py | 6 +++++- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index 2f13faab8a..a996151879 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -124,8 +124,7 @@ -{% settings_value "PART_SHOW_PRICE_HISTORY" as show_price_history %} -{% if show_price_history %} +{% if part.purchaseable or part.salable %}
{% include "part/prices.html" %}
diff --git a/InvenTree/part/templates/part/part_sidebar.html b/InvenTree/part/templates/part/part_sidebar.html index 3c1c647f86..e8763fb973 100644 --- a/InvenTree/part/templates/part/part_sidebar.html +++ b/InvenTree/part/templates/part/part_sidebar.html @@ -4,7 +4,6 @@ {% settings_value "PART_INTERNAL_PRICE" as show_internal_price %} {% settings_value 'PART_SHOW_RELATED' as show_related %} -{% settings_value "PART_SHOW_PRICE_HISTORY" as show_price_history %} {% trans "Parameters" as text %} {% include "sidebar_item.html" with label="part-parameters" text=text icon="fa-th-list" %} @@ -28,7 +27,7 @@ {% trans "Used In" as text %} {% include "sidebar_item.html" with label="used-in" text=text icon="fa-layer-group" %} {% endif %} -{% if show_price_history %} +{% if part.purchaseable or part.salable %} {% trans "Pricing" as text %} {% include "sidebar_item.html" with label="pricing" text=text icon="fa-dollar-sign" %} {% endif %} diff --git a/InvenTree/part/templates/part/prices.html b/InvenTree/part/templates/part/prices.html index 9b829c179b..53723b729b 100644 --- a/InvenTree/part/templates/part/prices.html +++ b/InvenTree/part/templates/part/prices.html @@ -3,6 +3,9 @@ {% load crispy_forms_tags %} {% load inventree_extras %} +{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %} + +{% if show_price_history %}

{% trans "Pricing Information" %}

@@ -43,7 +46,7 @@ {% endif %} {% endif %} - {% if part.bom_count > 0 %} + {% if part.assembly and part.bom_count > 0 %} {% if min_total_bom_price %} {% trans 'BOM Pricing' %} @@ -147,7 +150,7 @@ -{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %} +{% endif %} {% if part.purchaseable and roles.purchase_order.view %} @@ -170,7 +173,7 @@ -{% if price_history %} +{% if show_price_history %}

{% trans "Purchase Price" %} @@ -279,6 +282,7 @@

+{% if show_price_history %}

{% trans "Sale Price" %} @@ -298,3 +302,4 @@ {% endif %}

{% endif %} +{% endif %} diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 9f3cd07f7c..ab4b3fec72 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -389,8 +389,12 @@ class PartDetail(InvenTreeRoleMixin, DetailView): context.update(**ctx) + show_price_history = InvenTreeSetting.get_setting('PART_SHOW_PRICE_HISTORY', False) + + context['show_price_history'] = show_price_history + # Pricing information - if InvenTreeSetting.get_setting('PART_SHOW_PRICE_HISTORY', False): + if show_price_history: ctx = self.get_pricing(self.get_quantity()) ctx['form'] = self.form_class(initial=self.get_initials()) From 7db11b627b3c03d114cda773dd3cf9160c2271b9 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 26 Apr 2022 19:53:09 +1000 Subject: [PATCH 17/21] Refactor PartSalePriceBreak and PartInternalPriceBreak tables to use the API - Remove old server-side views - Simplify code --- InvenTree/InvenTree/api_version.py | 6 +- InvenTree/part/api.py | 20 +++++ InvenTree/part/serializers.py | 18 +++++ InvenTree/part/templates/part/detail.html | 4 +- InvenTree/part/templates/part/prices.html | 1 + InvenTree/part/urls.py | 18 ----- InvenTree/part/views.py | 99 ----------------------- InvenTree/templates/js/translated/part.js | 53 +++++++----- 8 files changed, 78 insertions(+), 141 deletions(-) diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index a49209dfe7..1ef1df6a8f 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -4,11 +4,15 @@ InvenTree API version information # InvenTree API version -INVENTREE_API_VERSION = 42 +INVENTREE_API_VERSION = 43 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v43 -> 2022-04-26 : https://github.com/inventree/InvenTree/pull/2875 + - Adds API detail endpoint for PartSalePrice model + - Adds API detail endpoint for PartInternalPrice model + v42 -> 2022-04-26 : https://github.com/inventree/InvenTree/pull/2833 - Adds variant stock information to the Part and BomItem serializers diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index b025791a7f..1a80c87322 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -262,6 +262,15 @@ class CategoryTree(generics.ListAPIView): ordering = ['level', 'name'] +class PartSalePriceDetail(generics.RetrieveUpdateDestroyAPIView): + """ + Detail endpoint for PartSellPriceBreak model + """ + + queryset = PartSellPriceBreak.objects.all() + serializer_class = part_serializers.PartSalePriceSerializer + + class PartSalePriceList(generics.ListCreateAPIView): """ API endpoint for list view of PartSalePriceBreak model @@ -279,6 +288,15 @@ class PartSalePriceList(generics.ListCreateAPIView): ] +class PartInternalPriceDetail(generics.RetrieveUpdateDestroyAPIView): + """ + Detail endpoint for PartInternalPriceBreak model + """ + + queryset = PartInternalPriceBreak.objects.all() + serializer_class = part_serializers.PartInternalPriceSerializer + + class PartInternalPriceList(generics.ListCreateAPIView): """ API endpoint for list view of PartInternalPriceBreak model @@ -1920,11 +1938,13 @@ part_api_urls = [ # Base URL for part sale pricing url(r'^sale-price/', include([ + url(r'^(?P\d+)/', PartSalePriceDetail.as_view(), name='api-part-sale-price-detail'), url(r'^.*$', PartSalePriceList.as_view(), name='api-part-sale-price-list'), ])), # Base URL for part internal pricing url(r'^internal-price/', include([ + url(r'^(?P\d+)/', PartInternalPriceDetail.as_view(), name='api-part-internal-price-detail'), url(r'^.*$', PartInternalPriceList.as_view(), name='api-part-internal-price-list'), ])), diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 387d3df1d5..0e865ea74b 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -17,6 +17,8 @@ from rest_framework import serializers from sql_util.utils import SubqueryCount, SubquerySum from djmoney.contrib.django_rest_framework import MoneyField +from common.settings import currency_code_default, currency_code_mappings + from InvenTree.serializers import (DataFileUploadSerializer, DataFileExtractSerializer, InvenTreeAttachmentSerializerField, @@ -148,6 +150,13 @@ class PartSalePriceSerializer(InvenTreeModelSerializer): allow_null=True ) + price_currency = serializers.ChoiceField( + choices=currency_code_mappings(), + default=currency_code_default, + label=_('Currency'), + help_text=_('Purchase currency of this stock item'), + ) + price_string = serializers.CharField(source='price', read_only=True) class Meta: @@ -157,6 +166,7 @@ class PartSalePriceSerializer(InvenTreeModelSerializer): 'part', 'quantity', 'price', + 'price_currency', 'price_string', ] @@ -172,6 +182,13 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer): allow_null=True ) + price_currency = serializers.ChoiceField( + choices=currency_code_mappings(), + default=currency_code_default, + label=_('Currency'), + help_text=_('Purchase currency of this stock item'), + ) + price_string = serializers.CharField(source='price', read_only=True) class Meta: @@ -181,6 +198,7 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer): 'part', 'quantity', 'price', + 'price_currency', 'price_string', ] diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index a996151879..5ec1821b3d 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -1008,7 +1008,7 @@ pb_url_slug: 'internal-price', pb_url: '{% url 'api-part-internal-price-list' %}', pb_new_btn: $('#new-internal-price-break'), - pb_new_url: '{% url 'internal-price-break-create' %}', + pb_new_url: '{% url 'api-part-internal-price-list' %}', linkedGraph: $('#InternalPriceBreakChart'), }, ); @@ -1024,7 +1024,7 @@ pb_url_slug: 'sale-price', pb_url: "{% url 'api-part-sale-price-list' %}", pb_new_btn: $('#new-price-break'), - pb_new_url: '{% url 'sale-price-break-create' %}', + pb_new_url: '{% url 'api-part-sale-price-list' %}', linkedGraph: $('#SalePriceBreakChart'), }, ); diff --git a/InvenTree/part/templates/part/prices.html b/InvenTree/part/templates/part/prices.html index 53723b729b..d47f49ef76 100644 --- a/InvenTree/part/templates/part/prices.html +++ b/InvenTree/part/templates/part/prices.html @@ -303,3 +303,4 @@ {% endif %} {% endif %} + \ No newline at end of file diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index 55a3dc52eb..04d2b0a5f8 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -13,18 +13,6 @@ from django.conf.urls import url, include from . import views -sale_price_break_urls = [ - url(r'^new/', views.PartSalePriceBreakCreate.as_view(), name='sale-price-break-create'), - url(r'^(?P\d+)/edit/', views.PartSalePriceBreakEdit.as_view(), name='sale-price-break-edit'), - url(r'^(?P\d+)/delete/', views.PartSalePriceBreakDelete.as_view(), name='sale-price-break-delete'), -] - -internal_price_break_urls = [ - url(r'^new/', views.PartInternalPriceBreakCreate.as_view(), name='internal-price-break-create'), - url(r'^(?P\d+)/edit/', views.PartInternalPriceBreakEdit.as_view(), name='internal-price-break-edit'), - url(r'^(?P\d+)/delete/', views.PartInternalPriceBreakDelete.as_view(), name='internal-price-break-delete'), -] - part_parameter_urls = [ url(r'^template/new/', views.PartParameterTemplateCreate.as_view(), name='part-param-template-create'), url(r'^template/(?P\d+)/edit/', views.PartParameterTemplateEdit.as_view(), name='part-param-template-edit'), @@ -86,12 +74,6 @@ part_urls = [ # Part category url(r'^category/', include(category_urls)), - # Part price breaks - url(r'^sale-price/', include(sale_price_break_urls)), - - # Part internal price breaks - url(r'^internal-price/', include(internal_price_break_urls)), - # Part parameters url(r'^parameter/', include(part_parameter_urls)), diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index ab4b3fec72..b0633d8c32 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -1230,102 +1230,3 @@ class CategoryParameterTemplateDelete(AjaxDeleteView): return None return self.object - - -class PartSalePriceBreakCreate(AjaxCreateView): - """ - View for creating a sale price break for a part - """ - - model = PartSellPriceBreak - form_class = part_forms.EditPartSalePriceBreakForm - ajax_form_title = _('Add Price Break') - - def get_data(self): - return { - 'success': _('Added new price break') - } - - def get_part(self): - try: - part = Part.objects.get(id=self.request.GET.get('part')) - except (ValueError, Part.DoesNotExist): - part = None - - if part is None: - try: - part = Part.objects.get(id=self.request.POST.get('part')) - except (ValueError, Part.DoesNotExist): - part = None - - return part - - def get_form(self): - - form = super(AjaxCreateView, self).get_form() - form.fields['part'].widget = HiddenInput() - - return form - - def get_initial(self): - - initials = super(AjaxCreateView, self).get_initial() - - initials['part'] = self.get_part() - - default_currency = inventree_settings.currency_code_default() - currency = CURRENCIES.get(default_currency, None) - - if currency is not None: - initials['price'] = [1.0, currency] - - return initials - - -class PartSalePriceBreakEdit(AjaxUpdateView): - """ View for editing a sale price break """ - - model = PartSellPriceBreak - form_class = part_forms.EditPartSalePriceBreakForm - ajax_form_title = _('Edit Price Break') - - def get_form(self): - - form = super().get_form() - form.fields['part'].widget = HiddenInput() - - return form - - -class PartSalePriceBreakDelete(AjaxDeleteView): - """ View for deleting a sale price break """ - - model = PartSellPriceBreak - ajax_form_title = _("Delete Price Break") - ajax_template_name = "modal_delete_form.html" - - -class PartInternalPriceBreakCreate(PartSalePriceBreakCreate): - """ View for creating a internal price break for a part """ - - model = PartInternalPriceBreak - form_class = part_forms.EditPartInternalPriceBreakForm - ajax_form_title = _('Add Internal Price Break') - permission_required = 'roles.sales_order.add' - - -class PartInternalPriceBreakEdit(PartSalePriceBreakEdit): - """ View for editing a internal price break """ - - model = PartInternalPriceBreak - form_class = part_forms.EditPartInternalPriceBreakForm - ajax_form_title = _('Edit Internal Price Break') - permission_required = 'roles.sales_order.change' - - -class PartInternalPriceBreakDelete(PartSalePriceBreakDelete): - """ View for deleting a internal price break """ - - model = PartInternalPriceBreak - ajax_form_title = _("Delete Internal Price Break") - permission_required = 'roles.sales_order.delete' diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 35d5d0d5a6..d552bcb9d7 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -1930,7 +1930,9 @@ function loadPriceBreakTable(table, options) { formatNoMatches: function() { return `{% trans "No ${human_name} information found" %}`; }, - queryParams: {part: options.part}, + queryParams: { + part: options.part + }, url: options.url, onLoadSuccess: function(tableData) { if (linkedGraph) { @@ -2036,36 +2038,45 @@ function initPriceBreakSet(table, options) { } pb_new_btn.click(function() { - launchModalForm(pb_new_url, - { - success: reloadPriceBreakTable, - data: { - part: part_id, - } - } - ); + + constructForm(pb_new_url, { + fields: { + part: { + hidden: true, + value: part_id, + }, + quantity: {}, + price: {}, + price_currency: {}, + }, + method: 'POST', + title: '{% trans "Add Price Break" %}', + onSuccess: reloadPriceBreakTable, + }); }); table.on('click', `.button-${pb_url_slug}-delete`, function() { var pk = $(this).attr('pk'); - launchModalForm( - `/part/${pb_url_slug}/${pk}/delete/`, - { - success: reloadPriceBreakTable - } - ); + constructForm(`${pb_url}${pk}/`, { + method: 'DELETE', + title: '{% trans "Delete Price Break" %}', + onSuccess: reloadPriceBreakTable, + }); }); table.on('click', `.button-${pb_url_slug}-edit`, function() { var pk = $(this).attr('pk'); - launchModalForm( - `/part/${pb_url_slug}/${pk}/edit/`, - { - success: reloadPriceBreakTable - } - ); + constructForm(`${pb_url}${pk}/`, { + fields: { + quantity: {}, + price: {}, + price_currency: {}, + }, + title: '{% trans "Edit Price Break" %}', + onSuccess: reloadPriceBreakTable, + }); }); } From 0b51ca290226e1234484ea2f04b949487c57d740 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 26 Apr 2022 19:54:16 +1000 Subject: [PATCH 18/21] PEP fixes --- InvenTree/part/views.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index b0633d8c32..41e734ce2a 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -18,7 +18,6 @@ from django.forms import HiddenInput from django.conf import settings from django.contrib import messages -from moneyed import CURRENCIES from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.exceptions import MissingRate @@ -33,7 +32,6 @@ from decimal import Decimal from .models import PartCategory, Part from .models import PartParameterTemplate from .models import PartCategoryParameterTemplate -from .models import PartSellPriceBreak, PartInternalPriceBreak from common.models import InvenTreeSetting from company.models import SupplierPart From 3f92f009e4d361febc3da06b32fc4db368bd9ce7 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 26 Apr 2022 20:00:06 +1000 Subject: [PATCH 19/21] Add configurable setting for batch code generation --- InvenTree/common/models.py | 6 ++++++ InvenTree/templates/InvenTree/settings/stock.html | 1 + 2 files changed, 7 insertions(+) diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index bb7de56e99..8bd100dcc9 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -932,6 +932,12 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'validator': bool, }, + 'STOCK_BATCH_CODE_TEMPLATE': { + 'name': _('Batch Code Template'), + 'description': _('Template for generating default batch codes for stock items'), + 'default': '', + }, + 'STOCK_ENABLE_EXPIRY': { 'name': _('Stock Expiry'), 'description': _('Enable stock expiry functionality'), diff --git a/InvenTree/templates/InvenTree/settings/stock.html b/InvenTree/templates/InvenTree/settings/stock.html index a3c0940c1f..f0cd403d68 100644 --- a/InvenTree/templates/InvenTree/settings/stock.html +++ b/InvenTree/templates/InvenTree/settings/stock.html @@ -11,6 +11,7 @@ + {% include "InvenTree/settings/setting.html" with key="STOCK_BATCH_CODE_TEMPLATE" icon="fa-layer-group" %} {% include "InvenTree/settings/setting.html" with key="STOCK_ENABLE_EXPIRY" icon="fa-stopwatch" %} {% include "InvenTree/settings/setting.html" with key="STOCK_STALE_DAYS" icon="fa-calendar" %} {% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_SALE" icon="fa-truck" %} From 314cec5ad0ec9f51949bf9f6038cae8e0d839f25 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 26 Apr 2022 20:18:33 +1000 Subject: [PATCH 20/21] Generate default batch code for stock items --- InvenTree/stock/models.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index fa343e9a9c..750b4df217 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -8,6 +8,8 @@ from __future__ import unicode_literals import os +from jinja2 import Template + from django.utils.translation import gettext_lazy as _ from django.core.exceptions import ValidationError, FieldError from django.urls import reverse @@ -213,6 +215,30 @@ class StockItemManager(TreeManager): ) +def generate_batch_code(): + """ + Generate a default 'batch code' for a new StockItem. + + This uses the value of the 'STOCK_BATCH_CODE_TEMPLATE' setting (if configured), + which can be passed through a simple template. + """ + + batch_template = common.models.InvenTreeSetting.get_setting('STOCK_BATCH_CODE_TEMPLATE', '') + + now = datetime.now() + + context = { + 'date': now, + 'year': now.year, + 'month': now.month, + 'day': now.day, + 'hour': now.minute, + 'minute': now.minute, + } + + return Template(batch_template).render(context) + + class StockItem(MPTTModel): """ A StockItem object represents a quantity of physical instances of a part. @@ -644,7 +670,8 @@ class StockItem(MPTTModel): batch = models.CharField( verbose_name=_('Batch Code'), max_length=100, blank=True, null=True, - help_text=_('Batch code for this stock item') + help_text=_('Batch code for this stock item'), + default=generate_batch_code, ) quantity = models.DecimalField( From 24da5d41b863259eb199fef3f1a42345e0956fa5 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 26 Apr 2022 20:21:15 +1000 Subject: [PATCH 21/21] Add migration file --- .../migrations/0074_alter_stockitem_batch.py | 19 +++++++++++++++++++ InvenTree/stock/models.py | 2 ++ 2 files changed, 21 insertions(+) create mode 100644 InvenTree/stock/migrations/0074_alter_stockitem_batch.py diff --git a/InvenTree/stock/migrations/0074_alter_stockitem_batch.py b/InvenTree/stock/migrations/0074_alter_stockitem_batch.py new file mode 100644 index 0000000000..646e25199a --- /dev/null +++ b/InvenTree/stock/migrations/0074_alter_stockitem_batch.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.12 on 2022-04-26 10:19 + +from django.db import migrations, models +import stock.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0073_alter_stockitem_belongs_to'), + ] + + operations = [ + migrations.AlterField( + model_name='stockitem', + name='batch', + field=models.CharField(blank=True, default=stock.models.generate_batch_code, help_text='Batch code for this stock item', max_length=100, null=True, verbose_name='Batch Code'), + ), + ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 750b4df217..39697c1bca 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -227,6 +227,8 @@ def generate_batch_code(): now = datetime.now() + # Pass context data through to the template randering. + # The folowing context variables are availble for custom batch code generation context = { 'date': now, 'year': now.year,