diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index a084d4dd1f5..3c703f149f4 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -2,11 +2,14 @@ # InvenTree API version -INVENTREE_API_VERSION = 111 +INVENTREE_API_VERSION = 112 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v112 -> 2023-05-13: https://github.com/inventree/InvenTree/pull/4741 + - Adds flag use_pack_size to the stock addition API, which allows addings packs + v111 -> 2023-05-02 : https://github.com/inventree/InvenTree/pull/4367 - Adds tags to the Part serializer - Adds tags to the SupplierPart serializer diff --git a/InvenTree/company/fixtures/supplier_part.yaml b/InvenTree/company/fixtures/supplier_part.yaml index 02cf33e12e4..0ebc07d8d97 100644 --- a/InvenTree/company/fixtures/supplier_part.yaml +++ b/InvenTree/company/fixtures/supplier_part.yaml @@ -59,3 +59,11 @@ part: 4 supplier: 2 SKU: 'R_4K7_0603' + +- model: company.supplierpart + pk: 6 + fields: + part: 4 + supplier: 2 + SKU: 'R_4K7_0603.100PCK' + pack_size: 100 diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 39c25ac23c5..3890c9bfc44 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -602,6 +602,35 @@ def create(self, request, *args, **kwargs): # Check if a set of serial numbers was provided serial_numbers = data.get('serial_numbers', '') + # Check if the supplier_part has a package size defined, which is not 1 + if 'supplier_part' in data and data['supplier_part'] is not None: + try: + supplier_part = SupplierPart.objects.get(pk=data.get('supplier_part', None)) + except (ValueError, SupplierPart.DoesNotExist): + raise ValidationError({ + 'supplier_part': _('The given supplier part does not exist'), + }) + + if supplier_part.pack_size != 1: + # Skip this check if pack size is 1 - makes no difference + # use_pack_size = True -> Multiply quantity by pack size + # use_pack_size = False -> Use quantity as is + if 'use_pack_size' not in data: + raise ValidationError({ + 'use_pack_size': _('The supplier part has a pack size defined, but flag use_pack_size not set'), + }) + else: + if bool(data.get('use_pack_size')): + data['quantity'] = int(quantity) * float(supplier_part.pack_size) + quantity = data.get('quantity', None) + # Divide purchase price by pack size, to save correct price per stock item + data['purchase_price'] = float(data['purchase_price']) / float(supplier_part.pack_size) + + # Now remove the flag from data, so that it doesn't interfere with saving + # Do this regardless of results above + if 'use_pack_size' in data: + data.pop('use_pack_size') + # Assign serial numbers for a trackable part if serial_numbers: diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 6775c53dbfa..92f6a795c64 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -124,6 +124,7 @@ class Meta: 'updated', 'purchase_price', 'purchase_price_currency', + 'use_pack_size', 'tags', ] @@ -140,6 +141,13 @@ class Meta: 'updated', ] + """ + Fields used when creating a stock item + """ + extra_kwargs = { + 'use_pack_size': {'write_only': True}, + } + part = serializers.PrimaryKeyRelatedField( queryset=part_models.Part.objects.all(), many=False, allow_null=False, @@ -147,6 +155,17 @@ class Meta: label=_("Part"), ) + """ + Field used when creating a stock item + """ + use_pack_size = serializers.BooleanField( + write_only=True, + required=False, + allow_null=True, + help_text=_("Use pack size when adding: the quantity defined is the number of packs"), + label=("Use pack size"), + ) + def validate_part(self, part): """Ensure the provided Part instance is valid""" @@ -231,7 +250,7 @@ def annotate_queryset(queryset): purchase_price = InvenTree.serializers.InvenTreeMoneySerializer( label=_('Purchase Price'), allow_null=True, - help_text=_('Purchase price of this stock item'), + help_text=_('Purchase price of this stock item, per unit or pack'), ) purchase_price_currency = InvenTreeCurrencySerializer(help_text=_('Purchase currency of this stock item')) diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index dc645e9eae9..de508af79c4 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -10,6 +10,7 @@ from django.urls import reverse import tablib +from djmoney.money import Money from rest_framework import status import company.models @@ -664,6 +665,113 @@ def test_stock_item_create(self): expected_code=201 ) + def test_stock_item_create_withsupplierpart(self): + """Test creation of a StockItem via the API, including SupplierPart data.""" + + # POST with non-existent supplier part + response = self.post( + self.list_url, + data={ + 'part': 1, + 'location': 1, + 'quantity': 4, + 'supplier_part': 1000991 + }, + expected_code=400 + ) + + self.assertIn('The given supplier part does not exist', str(response.data)) + + # POST with valid supplier part, no pack size defined + # Get current count of number of parts + part_4 = part.models.Part.objects.get(pk=4) + current_count = part_4.available_stock + response = self.post( + self.list_url, + data={ + 'part': 4, + 'location': 1, + 'quantity': 3, + 'supplier_part': 5, + 'purchase_price': 123.45, + 'purchase_price_currency': 'USD', + }, + expected_code=201 + ) + # Reload part, count stock again + part_4 = part.models.Part.objects.get(pk=4) + self.assertEqual(part_4.available_stock, current_count + 3) + stock_4 = StockItem.objects.get(pk=response.data['pk']) + self.assertEqual(stock_4.purchase_price, Money('123.450000', 'USD')) + + # POST with valid supplier part, no pack size defined + # Send use_pack_size along, make sure this doesn't break stuff + # Get current count of number of parts + part_4 = part.models.Part.objects.get(pk=4) + current_count = part_4.available_stock + response = self.post( + self.list_url, + data={ + 'part': 4, + 'location': 1, + 'quantity': 12, + 'supplier_part': 5, + 'use_pack_size': True, + 'purchase_price': 123.45, + 'purchase_price_currency': 'USD', + }, + expected_code=201 + ) + # Reload part, count stock again + part_4 = part.models.Part.objects.get(pk=4) + self.assertEqual(part_4.available_stock, current_count + 12) + stock_4 = StockItem.objects.get(pk=response.data['pk']) + self.assertEqual(stock_4.purchase_price, Money('123.450000', 'USD')) + + # POST with valid supplier part, WITH pack size defined - but ignore + # Supplier part 6 is a 100-pack, otherwise same as SP 5 + current_count = part_4.available_stock + response = self.post( + self.list_url, + data={ + 'part': 4, + 'location': 1, + 'quantity': 3, + 'supplier_part': 6, + 'use_pack_size': False, + 'purchase_price': 123.45, + 'purchase_price_currency': 'USD', + }, + expected_code=201 + ) + # Reload part, count stock again + part_4 = part.models.Part.objects.get(pk=4) + self.assertEqual(part_4.available_stock, current_count + 3) + stock_4 = StockItem.objects.get(pk=response.data['pk']) + self.assertEqual(stock_4.purchase_price, Money('123.450000', 'USD')) + + # POST with valid supplier part, WITH pack size defined and used + # Supplier part 6 is a 100-pack, otherwise same as SP 5 + current_count = part_4.available_stock + response = self.post( + self.list_url, + data={ + 'part': 4, + 'location': 1, + 'quantity': 3, + 'supplier_part': 6, + 'use_pack_size': True, + 'purchase_price': 123.45, + 'purchase_price_currency': 'USD', + }, + expected_code=201 + ) + # Reload part, count stock again + part_4 = part.models.Part.objects.get(pk=4) + self.assertEqual(part_4.available_stock, current_count + 3 * 100) + stock_4 = StockItem.objects.get(pk=response.data['pk']) + self.assertEqual(stock_4.purchase_price, Money('1.234500', 'USD')) + def test_creation_with_serials(self): """Test that serialized stock items can be created via the API.""" trackable_part = part.models.Part.objects.create( diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 679539de499..23a22c27e41 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -289,6 +289,9 @@ function stockItemFields(options={}) { return query; } }, + use_pack_size: { + help_text: '{% trans "Add given quantity as packs instead of individual items" %}', + }, location: { icon: 'fa-sitemap', filters: { diff --git a/docs/docs/stock/stock.md b/docs/docs/stock/stock.md index 228879cdc8b..4ec3c249f0e 100644 --- a/docs/docs/stock/stock.md +++ b/docs/docs/stock/stock.md @@ -35,3 +35,11 @@ The *Stock Item* detail view shows information regarding the particular stock it Every time a *Stock Item* is adjusted, a *Stock Tracking* entry is automatically created. This ensures a complete history of the *Stock Item* is maintained as long as the item is in the system. Each stock tracking historical item records the user who performed the action. + +## Supplier Part Pack Size + +Supplier parts can have a pack size defined. This value is defined when creating or editing a part. By default, the pack size is 1. + +When buying parts, they are bought in packs. This is taken into account in Purchase Orders: if a supplier part with a pack size of 5 is bought in a quantity of 4, 20 parts will be added to stock when the parts are received. + +When adding stock manually, the supplier part can be added in packs or in individual parts. This is to allow the addition of items in opened packages. Set the flag "Use pack size" (`use_pack_size` in the API) to True in order to add parts in packs.