From fdf79065a989dc1919370384eb18e4595228690a Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 20 Dec 2021 18:03:08 +1100 Subject: [PATCH 1/9] Adds framework for API endpoint and serializer to merge stock items --- InvenTree/stock/api.py | 15 +++++++++++++++ InvenTree/stock/serializers.py | 35 +++++++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 9961bb7bae..a016f9057f 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -180,6 +180,20 @@ class StockAssign(generics.CreateAPIView): return ctx +class StockMerge(generics.CreateAPIView): + """ + API endpoint for merging multiple stock items + """ + + queryset = StockItem.objects.none() + serializer_class = StockSerializers.StockMergeSerializer + + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['request'] = self.request + return ctx + + class StockLocationList(generics.ListCreateAPIView): """ API endpoint for list view of StockLocation objects: @@ -1213,6 +1227,7 @@ stock_api_urls = [ url(r'^remove/', StockRemove.as_view(), name='api-stock-remove'), url(r'^transfer/', StockTransfer.as_view(), name='api-stock-transfer'), url(r'^assign/', StockAssign.as_view(), name='api-stock-assign'), + url(r'^merge/', StockMerge.as_view(), name='api-stock-merge'), # StockItemAttachment API endpoints url(r'^attachment/', include([ diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index e69cd90f82..551d660f7b 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -674,6 +674,39 @@ class StockAssignmentSerializer(serializers.Serializer): ) +class StockMergeSerializer(serializers.Serializer): + """ + Serializer for merging two (or more) stock items together + """ + + class Meta: + fields = [ + # 'items', + 'location', + ] + + location = serializers.PrimaryKeyRelatedField( + queryset=StockLocation.objects.all(), + many=False, + required=True, + allow_null=False, + label=_('Location'), + help_text=_('Destination stock location'), + ) + + def validate(self, data): + + data = super().validate(data) + + # TODO: Custom data validation + + return data + + def save(self): + # TODO + pass + + class StockAdjustmentItemSerializer(serializers.Serializer): """ Serializer for a single StockItem within a stock adjument request. @@ -837,7 +870,7 @@ class StockTransferSerializer(StockAdjustmentSerializer): def validate(self, data): - super().validate(data) + data = super().validate(data) # TODO: Any specific validation of location field? From 5ca50022b913caace03be271b377185d397a7cfc Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 20 Dec 2021 18:47:51 +1100 Subject: [PATCH 2/9] Incremenent 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 5dd9c812aa..79bc44bc0e 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -12,10 +12,14 @@ import common.models INVENTREE_SW_VERSION = "0.6.0 dev" # InvenTree API version -INVENTREE_API_VERSION = 21 +INVENTREE_API_VERSION = 22 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about + +v22 -> 2021-12-20 + - Adds API endpoint to "merge" multiple stock items + v21 -> 2021-12-04 - Adds support for multiple "Shipments" against a SalesOrder - Refactors process for stock allocation against a SalesOrder From 943b27e1958aee67607c22f5a32feb740e9e932a Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 20 Dec 2021 19:29:08 +1100 Subject: [PATCH 3/9] Adds "items" list to API endpoint --- InvenTree/order/models.py | 28 ++++++++++++----------- InvenTree/stock/models.py | 42 ++++++++++++++++++++++++++++++++++ InvenTree/stock/serializers.py | 39 +++++++++++++++++++++++++++++-- 3 files changed, 94 insertions(+), 15 deletions(-) diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 3a7d39d7b5..e798ee4e30 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -628,28 +628,30 @@ class SalesOrder(Order): Throws a ValidationError if cannot be completed. """ - # Order without line items cannot be completed - if self.lines.count() == 0: - if raise_error: + try: + + # Order without line items cannot be completed + if self.lines.count() == 0: raise ValidationError(_('Order cannot be completed as no parts have been assigned')) - # Only a PENDING order can be marked as SHIPPED - elif self.status != SalesOrderStatus.PENDING: - if raise_error: + # Only a PENDING order can be marked as SHIPPED + elif self.status != SalesOrderStatus.PENDING: raise ValidationError(_('Only a pending order can be marked as complete')) - elif self.pending_shipment_count > 0: - if raise_error: + elif self.pending_shipment_count > 0: raise ValidationError(_("Order cannot be completed as there are incomplete shipments")) - elif self.pending_line_count > 0: - if raise_error: + elif self.pending_line_count > 0: raise ValidationError(_("Order cannot be completed as there are incomplete line items")) - else: - return True + except ValidationError as e: - return False + if raise_error: + raise e + else: + return False + + return True def complete_order(self, user): """ diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 7f385e7136..2c9773e6e7 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -1153,6 +1153,48 @@ class StockItem(MPTTModel): result.stock_item = self result.save() + def can_merge(self, other=None, raise_error=False): + """ + Check if this stock item can be merged into another + """ + + try: + if not self.in_stock: + raise ValidationError(_("Item must be in stock")) + + if self.serialized: + raise ValidationError(_("Serialized stock cannot be merged")) + + except ValidationError as e: + if raise_error: + raise e + else: + return False + + return True + + @transaction.atomic + def merge_stock_item(self, other, **kwargs): + """ + Merge another stock item into this one; the two become one! + + *This* stock item subsumes the other, which is essentially deleted: + + - The quantity of this StockItem is increased + - Tracking history for the *other* item is deleted + - Any allocations (build order, sales order) are moved to this StockItem + """ + + # If the stock item cannot be merged, return + if not self.can_merge(other): + return + + user = kwargs.get('user', None) + location = kwargs.get('location', None) + + # TODO: Merge! + + @transaction.atomic def splitStock(self, quantity, location, user, **kwargs): """ Split this stock item into two items, in the same location. diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 551d660f7b..cf30252cac 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -674,6 +674,33 @@ class StockAssignmentSerializer(serializers.Serializer): ) +class StockMergeItemSerializer(serializers.Serializer): + """ + Serializer for a single StockItem within the StockMergeSerializer class. + + Here, the individual StockItem is being checked for merge compatibility. + """ + + class Meta: + fields = [ + 'item', + ] + + item = serializers.PrimaryKeyRelatedField( + queryset=StockItem.objects.all(), + many=False, + allow_null=False, + required=True, + label=_('Stock Item'), + ) + + def validate_item(self, item): + + # Check that the stock item is able to be merged + item.can_merge(raise_error=True) + + return item + class StockMergeSerializer(serializers.Serializer): """ Serializer for merging two (or more) stock items together @@ -681,10 +708,15 @@ class StockMergeSerializer(serializers.Serializer): class Meta: fields = [ - # 'items', + 'items', 'location', ] + items = StockMergeItemSerializer( + many=True, + required=True, + ) + location = serializers.PrimaryKeyRelatedField( queryset=StockLocation.objects.all(), many=False, @@ -698,7 +730,10 @@ class StockMergeSerializer(serializers.Serializer): data = super().validate(data) - # TODO: Custom data validation + items = data['items'] + + if len(items) < 2: + raise ValidationError(_('At least two stock items must be provided')) return data From 31dbb9563b78c735fda1036a3bf8998f259f9522 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 20 Dec 2021 21:25:27 +1100 Subject: [PATCH 4/9] Add some unit tests for the new endpoint --- InvenTree/InvenTree/status_codes.py | 5 + InvenTree/stock/fixtures/stock.yaml | 13 -- InvenTree/stock/models.py | 82 ++++++++++-- InvenTree/stock/serializers.py | 79 +++++++++++- InvenTree/stock/test_api.py | 186 ++++++++++++++++++++++++++++ 5 files changed, 342 insertions(+), 23 deletions(-) diff --git a/InvenTree/InvenTree/status_codes.py b/InvenTree/InvenTree/status_codes.py index 656c1d6e51..c8917d679b 100644 --- a/InvenTree/InvenTree/status_codes.py +++ b/InvenTree/InvenTree/status_codes.py @@ -252,6 +252,9 @@ class StockHistoryCode(StatusCode): SPLIT_FROM_PARENT = 40 SPLIT_CHILD_ITEM = 42 + # Stock merging operations + MERGED_STOCK_ITEMS = 45 + # Build order codes BUILD_OUTPUT_CREATED = 50 BUILD_OUTPUT_COMPLETED = 55 @@ -288,6 +291,8 @@ class StockHistoryCode(StatusCode): SPLIT_FROM_PARENT: _('Split from parent item'), SPLIT_CHILD_ITEM: _('Split child item'), + MERGED_STOCK_ITEMS: _('Merged stock items'), + SENT_TO_CUSTOMER: _('Sent to customer'), RETURNED_FROM_CUSTOMER: _('Returned from customer'), diff --git a/InvenTree/stock/fixtures/stock.yaml b/InvenTree/stock/fixtures/stock.yaml index 25458a28e1..23012c3cd3 100644 --- a/InvenTree/stock/fixtures/stock.yaml +++ b/InvenTree/stock/fixtures/stock.yaml @@ -114,19 +114,6 @@ lft: 0 rght: 0 -- model: stock.stockitem - pk: 501 - fields: - part: 10001 - location: 7 - batch: "AAA" - quantity: 1 - serial: 1 - level: 0 - tree_id: 0 - lft: 0 - rght: 0 - - model: stock.stockitem pk: 501 fields: diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 2c9773e6e7..1d287cd307 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -1153,18 +1153,51 @@ class StockItem(MPTTModel): result.stock_item = self result.save() - def can_merge(self, other=None, raise_error=False): + def can_merge(self, other=None, raise_error=False, **kwargs): """ - Check if this stock item can be merged into another + Check if this stock item can be merged into another stock item """ + allow_mismatched_suppliers = kwargs.get('allow_mismatched_suppliers', False) + + allow_mismatched_status = kwargs.get('allow_mismatched_status', False) + try: - if not self.in_stock: - raise ValidationError(_("Item must be in stock")) + # Generic checks (do not rely on the 'other' part) + if self.sales_order: + raise ValidationError(_('Stock item has been assigned to a sales order')) + + if self.belongs_to: + raise ValidationError(_('Stock item is installed in another item')) + + if self.customer: + raise ValidationError(_('Stock item has been assigned to a customer')) + + if self.is_building: + raise ValidationError(_('Stock item is currently in production')) if self.serialized: raise ValidationError(_("Serialized stock cannot be merged")) + if other: + # Specific checks (rely on the 'other' part) + + # Prevent stock item being merged with itself + if self == other: + raise ValidationError(_('Duplicate stock items')) + + # Base part must match + if self.part != other.part: + raise ValidationError(_("Stock items must refer to the same part")) + + # Check if supplier part references match + if self.supplier_part != other.supplier_part and not allow_mismatched_suppliers: + raise ValidationError(_("Stock items must refer to the same supplier part")) + + # Check if stock status codes match + if self.status != other.status and not allow_mismatched_status: + raise ValidationError(_("Stock status codes must match")) + except ValidationError as e: if raise_error: raise e @@ -1174,7 +1207,7 @@ class StockItem(MPTTModel): return True @transaction.atomic - def merge_stock_item(self, other, **kwargs): + def merge_stock_items(self, other_items, raise_error=False, **kwargs): """ Merge another stock item into this one; the two become one! @@ -1185,15 +1218,48 @@ class StockItem(MPTTModel): - Any allocations (build order, sales order) are moved to this StockItem """ - # If the stock item cannot be merged, return - if not self.can_merge(other): + if len(other_items) == 0: return user = kwargs.get('user', None) location = kwargs.get('location', None) + notes = kwargs.get('notes', None) - # TODO: Merge! + for other in other_items: + # If the stock item cannot be merged, return + if not self.can_merge(other, raise_error=raise_error, **kwargs): + return + for other in other_items: + + self.quantity += other.quantity + + # Any "build order allocations" for the other item must be assigned to this one + for allocation in other.allocations.all(): + + allocation.stock_item = self + allocation.save() + + # Any "sales order allocations" for the other item must be assigned to this one + for allocation in other.sales_order_allocations.all(): + + allocation.stock_item = self() + allocation.save() + + # Delete the other stock item + other.delete() + + self.add_tracking_entry( + StockHistoryCode.MERGED_STOCK_ITEMS, + user, + notes=notes, + deltas={ + 'location': location.pk, + } + ) + + self.location = location + self.save() @transaction.atomic def splitStock(self, quantity, location, user, **kwargs): diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index cf30252cac..681ead9caf 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -701,6 +701,7 @@ class StockMergeItemSerializer(serializers.Serializer): return item + class StockMergeSerializer(serializers.Serializer): """ Serializer for merging two (or more) stock items together @@ -710,6 +711,9 @@ class StockMergeSerializer(serializers.Serializer): fields = [ 'items', 'location', + 'notes', + 'allow_mismatched_suppliers', + 'allow_mismatched_status', ] items = StockMergeItemSerializer( @@ -726,6 +730,25 @@ class StockMergeSerializer(serializers.Serializer): help_text=_('Destination stock location'), ) + notes = serializers.CharField( + required=False, + allow_blank=True, + label=_('Notes'), + help_text=_('Stock merging notes'), + ) + + allow_mismatched_suppliers = serializers.BooleanField( + required=False, + label=_('Allow mismatched suppliers'), + help_text=_('Allow stock items with different supplier parts to be merged'), + ) + + allow_mismatched_status = serializers.BooleanField( + required=False, + label=_('Allow mismatched status'), + help_text=_('Allow stock items with different status codes to be merged'), + ) + def validate(self, data): data = super().validate(data) @@ -735,11 +758,63 @@ class StockMergeSerializer(serializers.Serializer): if len(items) < 2: raise ValidationError(_('At least two stock items must be provided')) + unique_items = set() + + # The "base item" is the first item + base_item = items[0]['item'] + + data['base_item'] = base_item + + # Ensure stock items are unique! + for element in items: + item = element['item'] + + if item.pk in unique_items: + raise ValidationError(_('Duplicate stock items')) + + unique_items.add(item.pk) + + # Checks from here refer to the "base_item" + if item == base_item: + continue + + # Check that this item can be merged with the base_item + item.can_merge( + raise_error=True, + other=base_item, + allow_mismatched_suppliers=data.get('allow_mismatched_suppliers', False), + allow_mismatched_status=data.get('allow_mismatched_status', False), + ) + return data def save(self): - # TODO - pass + """ + Actually perform the stock merging action. + At this point we are confident that the merge can take place + """ + + data = self.validated_data + + base_item = data['base_item'] + items = data['items'][1:] + + request = self.context['request'] + user = getattr(request, 'user', None) + + items = [] + + for item in data['items'][1:]: + items.append(item['item']) + + base_item.merge_stock_items( + items, + allow_mismatched_suppliers=data.get('allow_mismatched_suppliers', False), + allow_mismatched_status=data.get('allow_mismatched_status', False), + user=user, + location=data['location'], + notes=data.get('notes', None) + ) class StockAdjustmentItemSerializer(serializers.Serializer): diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index fe76e6c1c0..bd02646a81 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -842,3 +842,189 @@ class StockAssignTest(StockAPITestCase): # 5 stock items should now have been assigned to this customer self.assertEqual(customer.assigned_stock.count(), 5) + + +class StockMergeTest(StockAPITestCase): + """ + Unit tests for merging stock items via the API + """ + + URL = reverse('api-stock-merge') + + def setUp(self): + + super().setUp() + + self.part = part.models.Part.objects.get(pk=25) + self.loc = StockLocation.objects.get(pk=1) + self.sp_1 = company.models.SupplierPart.objects.get(pk=100) + self.sp_2 = company.models.SupplierPart.objects.get(pk=101) + + self.item_1 = StockItem.objects.create( + part=self.part, + supplier_part=self.sp_1, + quantity=100, + ) + + self.item_2 = StockItem.objects.create( + part=self.part, + supplier_part=self.sp_2, + quantity=100, + ) + + self.item_3 = StockItem.objects.create( + part=self.part, + supplier_part=self.sp_2, + quantity=50, + ) + + def test_missing_data(self): + """ + Test responses which are missing required data + """ + + # Post completely empty + + data = self.post( + self.URL, + {}, + expected_code=400 + ).data + + self.assertIn('This field is required', str(data['items'])) + self.assertIn('This field is required', str(data['location'])) + + # Post with a location and empty items list + data = self.post( + self.URL, + { + 'items': [], + 'location': 1, + }, + expected_code=400 + ).data + + self.assertIn('At least two stock items', str(data)) + + def test_invalid_data(self): + """ + Test responses which have invalid data + """ + + # Serialized stock items should be rejected + data = self.post( + self.URL, + { + 'items': [ + { + 'item': 501, + }, + { + 'item': 502, + } + ], + 'location': 1, + }, + expected_code=400, + ).data + + self.assertIn('Serialized stock cannot be merged', str(data)) + + # Prevent item duplication + + data = self.post( + self.URL, + { + 'items': [ + { + 'item': 11, + }, + { + 'item': 11, + } + ], + 'location': 1, + }, + expected_code=400, + ).data + + self.assertIn('Duplicate stock items', str(data)) + + # Check for mismatching stock items + data = self.post( + self.URL, + { + 'items': [ + { + 'item': 1234, + }, + { + 'item': 11, + } + ], + 'location': 1, + }, + expected_code=400, + ).data + + self.assertIn('Stock items must refer to the same part', str(data)) + + # Check for mismatching supplier parts + payload = { + 'items': [ + { + 'item': self.item_1.pk, + }, + { + 'item': self.item_2.pk, + }, + ], + 'location': 1, + } + + data = self.post( + self.URL, + payload, + expected_code=400, + ).data + + self.assertIn('Stock items must refer to the same supplier part', str(data)) + + def test_valid_merge(self): + """ + Test valid merging of stock items + """ + + # Check initial conditions + n = StockItem.objects.filter(part=self.part).count() + self.assertEqual(self.item_1.quantity, 100) + + payload = { + 'items': [ + { + 'item': self.item_1.pk, + }, + { + 'item': self.item_2.pk, + }, + { + 'item': self.item_3.pk, + }, + ], + 'location': 1, + 'allow_mismatched_suppliers': True, + } + + self.post( + self.URL, + payload, + expected_code=201, + ) + + self.item_1.refresh_from_db() + + # Stock quantity should have been increased! + self.assertEqual(self.item_1.quantity, 250) + + # Total number of stock items has been reduced! + self.assertEqual(StockItem.objects.filter(part=self.part).count(), n - 2) From 552ca8e5c4358297157573faa6a9a63898048b93 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 20 Dec 2021 22:09:08 +1100 Subject: [PATCH 5/9] Adds javascript function to merge multiple stock items together --- InvenTree/templates/js/translated/stock.js | 219 +++++++++++++++++++-- InvenTree/templates/stock_table.html | 1 + 2 files changed, 207 insertions(+), 13 deletions(-) diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 6360f396bb..23db95ccba 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -52,6 +52,7 @@ loadStockTestResultsTable, loadStockTrackingTable, loadTableFilters, + mergeStockItems, removeStockRow, serializeStockItem, stockItemFields, @@ -595,17 +596,17 @@ function assignStockToCustomer(items, options={}) { buttons += ''; html += ` - - ${thumbnail} ${part.full_name} - -
- ${quantity} -
-
- - ${location} - ${buttons} - + + ${thumbnail} ${part.full_name} + +
+ ${quantity} +
+
+ + ${location} + ${buttons} + `; } @@ -615,13 +616,13 @@ function assignStockToCustomer(items, options={}) { method: 'POST', preFormContent: html, fields: { - 'customer': { + customer: { value: options.customer, filters: { is_customer: true, }, }, - 'notes': {}, + notes: {}, }, confirm: true, confirmMessage: '{% trans "Confirm stock assignment" %}', @@ -694,6 +695,184 @@ function assignStockToCustomer(items, options={}) { } +/** + * Merge multiple stock items together + */ +function mergeStockItems(items, options={}) { + + // Generate HTML content for the form + var html = ` +
+
{% trans "Warning: Merge operation cannot be reversed" %}
+ {% trans "Some information will be lost when merging stock items" %}: +
    +
  • {% trans "Stock transaction history will be deleted for merged items" %}
  • +
  • {% trans "Supplier part information will be deleted for merged items" %}
  • +
+
+ `; + + html += ` + + + + + + + + + + + `; + + // Keep track of how many "locations" there are + var locations = []; + + for (var idx = 0; idx < items.length; idx++) { + var item = items[idx]; + + var pk = item.pk; + + if (item.location && !locations.includes(item.location)) { + locations.push(item.location); + } + + var part = item.part_detail; + var location = locationDetail(item, false); + + var thumbnail = thumbnailImage(part.thumbnail || part.image); + + var quantity = ''; + + if (item.serial && item.quantity == 1) { + quantity = `{% trans "Serial" %}: ${item.serial}`; + } else { + quantity = `{% trans "Quantity" %}: ${item.quantity}`; + } + + quantity += stockStatusDisplay(item.status, {classes: 'float-right'}); + + var buttons = `
`; + + buttons += makeIconButton( + 'fa-times icon-red', + 'button-stock-item-remove', + pk, + '{% trans "Remove row" %}', + ); + + html += ` +
+ + + + + + `; + } + + html += '
{% trans "Part" %}{% trans "Stock Item" %}{% trans "Location" %}
${thumbnail} ${part.full_name} +
+ ${quantity} +
+
+
${location}${buttons}
'; + + var location = locations.length == 1 ? locations[0] : null; + + constructForm('{% url "api-stock-merge" %}', { + method: 'POST', + preFormContent: html, + fields: { + location: { + value: location, + icon: 'fa-sitemap', + }, + notes: {}, + allow_mismatched_suppliers: {}, + allow_mismatched_status: {}, + }, + confirm: true, + confirmMessage: '{% trans "Confirm stock item merge" %}', + title: '{% trans "Merge Stock Items" %}', + afterRender: function(fields, opts) { + // Add button callbacks to remove rows + $(opts.modal).find('.button-stock-item-remove').click(function() { + var pk = $(this).attr('pk'); + + $(opts.modal).find(`#stock_item_${pk}`).remove(); + }); + }, + onSubmit: function(fields, opts) { + + // Extract data elements from the form + var data = { + items: [], + }; + + var item_pk_values = []; + + items.forEach(function(item) { + var pk = item.pk; + + // Does the row still exist in the form? + var row = $(opts.modal).find(`#stock_item_${pk}`); + + if (row.exists()) { + item_pk_values.push(pk); + + data.items.push({ + item: pk, + }); + } + }); + + var extra_fields = [ + 'location', + 'notes', + 'allow_mismatched_suppliers', + 'allow_mismatched_status', + ]; + + extra_fields.forEach(function(field) { + data[field] = getFormFieldValue(field, fields[field], opts); + }); + + opts.nested = { + 'items': item_pk_values + }; + + // Submit the form data + inventreePut( + '{% url "api-stock-merge" %}', + data, + { + method: 'POST', + success: function(response) { + $(opts.modal).modal('hide'); + + if (options.success) { + options.success(response); + } + }, + error: function(xhr) { + switch (xhr.status) { + case 400: + handleFormErrors(xhr.responseJSON, fields, opts); + break; + default: + $(opts.modal).modal('hide'); + showApiError(xhr, opts.url); + break; + } + } + } + ) + } + }); +} + + /** * Perform stock adjustments */ @@ -1875,6 +2054,20 @@ function loadStockTable(table, options) { stockAdjustment('move'); }); + $('#multi-item-merge').click(function() { + var items = $(table).bootstrapTable('getSelections'); + + mergeStockItems(items, { + success: function(response) { + $(table).bootstrapTable('refresh'); + + showMessage('{% trans "Merged stock items" %}', { + style: 'success', + }); + } + }); + }); + $('#multi-item-assign').click(function() { var items = $(table).bootstrapTable('getSelections'); diff --git a/InvenTree/templates/stock_table.html b/InvenTree/templates/stock_table.html index 1f873d7c58..4a20938869 100644 --- a/InvenTree/templates/stock_table.html +++ b/InvenTree/templates/stock_table.html @@ -49,6 +49,7 @@
  • {% trans "Remove stock" %}
  • {% trans "Count stock" %}
  • {% trans "Move stock" %}
  • +
  • {% trans "Merge stock" %}
  • {% trans "Order stock" %}
  • {% trans "Assign to customer" %}
  • {% trans "Change stock status" %}
  • From fcb2bb2a46f0478b1eaec7533f7b64e8eb8b1698 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 21 Dec 2021 01:03:37 +1100 Subject: [PATCH 6/9] Fix behaviour on deleting parent stock item --- .../migrations/0073_alter_stockitem_parent.py | 20 +++++++++++++++++++ InvenTree/stock/models.py | 3 ++- InvenTree/templates/js/translated/stock.js | 2 +- 3 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 InvenTree/stock/migrations/0073_alter_stockitem_parent.py diff --git a/InvenTree/stock/migrations/0073_alter_stockitem_parent.py b/InvenTree/stock/migrations/0073_alter_stockitem_parent.py new file mode 100644 index 0000000000..dca8788964 --- /dev/null +++ b/InvenTree/stock/migrations/0073_alter_stockitem_parent.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.10 on 2021-12-20 13:54 + +from django.db import migrations +import django.db.models.deletion +import mptt.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0072_remove_stockitem_scheduled_for_deletion'), + ] + + operations = [ + migrations.AlterField( + model_name='stockitem', + name='parent', + field=mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='stock.stockitem', verbose_name='Parent Stock Item'), + ), + ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 1d287cd307..6a9f12bd77 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -458,7 +458,7 @@ class StockItem(MPTTModel): parent = TreeForeignKey( 'self', verbose_name=_('Parent Stock Item'), - on_delete=models.DO_NOTHING, + on_delete=models.SET_NULL, blank=True, null=True, related_name='children' ) @@ -1252,6 +1252,7 @@ class StockItem(MPTTModel): self.add_tracking_entry( StockHistoryCode.MERGED_STOCK_ITEMS, user, + quantity=self.quantity, notes=notes, deltas={ 'location': location.pk, diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 23db95ccba..77aed29c11 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -867,7 +867,7 @@ function mergeStockItems(items, options={}) { } } } - ) + ); } }); } From 785c5edf9644bd584696b1a1f1c791709b740247 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 21 Dec 2021 08:13:38 +1100 Subject: [PATCH 7/9] Reduce verbosity of translation coverage stats script --- InvenTree/script/translation_stats.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/InvenTree/script/translation_stats.py b/InvenTree/script/translation_stats.py index c6126f2944..2b0e8d639c 100644 --- a/InvenTree/script/translation_stats.py +++ b/InvenTree/script/translation_stats.py @@ -4,6 +4,7 @@ This script calculates translation coverage for various languages import os import json +import sys def calculate_coverage(filename): @@ -42,7 +43,7 @@ if __name__ == '__main__': locales = {} locales_perc = {} - print("InvenTree translation coverage:") + verbose = '-v' in sys.argv for locale in os.listdir(LC_DIR): path = os.path.join(LC_DIR, locale) @@ -53,7 +54,10 @@ if __name__ == '__main__': if os.path.exists(locale_file) and os.path.isfile(locale_file): locales[locale] = locale_file - print("-" * 16) + if verbose: + print("-" * 16) + + percentages = [] for locale in locales.keys(): locale_file = locales[locale] @@ -66,11 +70,23 @@ if __name__ == '__main__': else: percentage = 0 - print(f"| {locale.ljust(4, ' ')} : {str(percentage).rjust(4, ' ')}% |") + if verbose: + print(f"| {locale.ljust(4, ' ')} : {str(percentage).rjust(4, ' ')}% |") + locales_perc[locale] = percentage - print("-" * 16) + percentages.append(percentage) + + if verbose: + print("-" * 16) # write locale stats with open(STAT_FILE, 'w') as target: json.dump(locales_perc, target) + + if len(percentages) > 0: + avg = int(sum(percentages) / len(percentages)) + else: + avg = 0 + + print(f"InvenTree translation coverage: {avg}%") \ No newline at end of file From dd53748f9f0b96abce95887af0c6a01d207c412a Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 21 Dec 2021 08:49:53 +1100 Subject: [PATCH 8/9] Cleanup models --- .../0073_alter_stockitem_belongs_to.py | 19 ++++++++++++ .../migrations/0073_alter_stockitem_parent.py | 20 ------------ InvenTree/stock/models.py | 31 ++++++++++--------- .../stock/templates/stock/item_base.html | 8 ----- 4 files changed, 36 insertions(+), 42 deletions(-) create mode 100644 InvenTree/stock/migrations/0073_alter_stockitem_belongs_to.py delete mode 100644 InvenTree/stock/migrations/0073_alter_stockitem_parent.py diff --git a/InvenTree/stock/migrations/0073_alter_stockitem_belongs_to.py b/InvenTree/stock/migrations/0073_alter_stockitem_belongs_to.py new file mode 100644 index 0000000000..93000ef0f8 --- /dev/null +++ b/InvenTree/stock/migrations/0073_alter_stockitem_belongs_to.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.10 on 2021-12-20 21:49 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0072_remove_stockitem_scheduled_for_deletion'), + ] + + operations = [ + migrations.AlterField( + model_name='stockitem', + name='belongs_to', + field=models.ForeignKey(blank=True, help_text='Is this item installed in another item?', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='installed_parts', to='stock.stockitem', verbose_name='Installed In'), + ), + ] diff --git a/InvenTree/stock/migrations/0073_alter_stockitem_parent.py b/InvenTree/stock/migrations/0073_alter_stockitem_parent.py deleted file mode 100644 index dca8788964..0000000000 --- a/InvenTree/stock/migrations/0073_alter_stockitem_parent.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 3.2.10 on 2021-12-20 13:54 - -from django.db import migrations -import django.db.models.deletion -import mptt.fields - - -class Migration(migrations.Migration): - - dependencies = [ - ('stock', '0072_remove_stockitem_scheduled_for_deletion'), - ] - - operations = [ - migrations.AlterField( - model_name='stockitem', - name='parent', - field=mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='stock.stockitem', verbose_name='Parent Stock Item'), - ), - ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 6a9f12bd77..48db47ba0d 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -455,10 +455,11 @@ class StockItem(MPTTModel): uid = models.CharField(blank=True, max_length=128, help_text=("Unique identifier field")) + # Note: When a StockItem is deleted, a pre_delete signal handles the parent/child relationship parent = TreeForeignKey( 'self', verbose_name=_('Parent Stock Item'), - on_delete=models.SET_NULL, + on_delete=models.DO_NOTHING, blank=True, null=True, related_name='children' ) @@ -477,6 +478,7 @@ class StockItem(MPTTModel): help_text=_('Select a matching supplier part for this stock item') ) + # Note: When a StockLocation is deleted, stock items are updated via a signal location = TreeForeignKey( StockLocation, on_delete=models.DO_NOTHING, verbose_name=_('Stock Location'), @@ -492,10 +494,11 @@ class StockItem(MPTTModel): help_text=_('Packaging this stock item is stored in') ) + # When deleting a stock item with installed items, those installed items are also installed belongs_to = models.ForeignKey( 'self', verbose_name=_('Installed In'), - on_delete=models.DO_NOTHING, + on_delete=models.CASCADE, related_name='installed_parts', blank=True, null=True, help_text=_('Is this item installed in another item?') ) @@ -800,14 +803,14 @@ class StockItem(MPTTModel): def can_delete(self): """ Can this stock item be deleted? It can NOT be deleted under the following circumstances: - - Has child StockItems + - Has installed stock items - Has a serial number and is tracked - Is installed inside another StockItem - It has been assigned to a SalesOrder - It has been assigned to a BuildOrder """ - if self.child_count > 0: + if self.installed_item_count() > 0: return False if self.part.trackable and self.serial is not None: @@ -853,20 +856,13 @@ class StockItem(MPTTModel): return installed - def installedItemCount(self): + def installed_item_count(self): """ Return the number of stock items installed inside this one. """ return self.installed_parts.count() - def hasInstalledItems(self): - """ - Returns true if this stock item has other stock items installed in it. - """ - - return self.installedItemCount() > 0 - @transaction.atomic def installStockItem(self, other_item, quantity, user, notes): """ @@ -1225,6 +1221,8 @@ class StockItem(MPTTModel): location = kwargs.get('location', None) notes = kwargs.get('notes', None) + parent_id = self.parent.pk if self.parent else None + for other in other_items: # If the stock item cannot be merged, return if not self.can_merge(other, raise_error=raise_error, **kwargs): @@ -1246,7 +1244,11 @@ class StockItem(MPTTModel): allocation.stock_item = self() allocation.save() - # Delete the other stock item + # Prevent atomicity issues when we are merging our own "parent" part in + if parent_id and parent_id == other.pk: + self.parent = None + self.save() + other.delete() self.add_tracking_entry( @@ -1757,7 +1759,8 @@ class StockItem(MPTTModel): @receiver(pre_delete, sender=StockItem, dispatch_uid='stock_item_pre_delete_log') def before_delete_stock_item(sender, instance, using, **kwargs): - """ Receives pre_delete signal from StockItem object. + """ + Receives pre_delete signal from StockItem object. Before a StockItem is deleted, ensure that each child object is updated, to point to the new parent item. diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 65f5f21000..9113425520 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -274,14 +274,6 @@
    {% trans "This stock item is serialized - it has a unique serial number and the quantity cannot be adjusted." %}
    - {% elif item.child_count > 0 %} -
    - {% trans "This stock item cannot be deleted as it has child items" %} -
    - {% elif item.delete_on_deplete and item.can_delete %} -
    - {% trans "This stock item will be automatically deleted when all stock is depleted." %} -
    {% endif %} From 52ca2e5068cdd7ce8f74d061d0beebac18ef58c9 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 21 Dec 2021 09:29:12 +1100 Subject: [PATCH 9/9] Add another check for merging stock --- InvenTree/script/translation_stats.py | 4 ++-- InvenTree/stock/models.py | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/InvenTree/script/translation_stats.py b/InvenTree/script/translation_stats.py index 2b0e8d639c..4ee83120eb 100644 --- a/InvenTree/script/translation_stats.py +++ b/InvenTree/script/translation_stats.py @@ -72,7 +72,7 @@ if __name__ == '__main__': if verbose: print(f"| {locale.ljust(4, ' ')} : {str(percentage).rjust(4, ' ')}% |") - + locales_perc[locale] = percentage percentages.append(percentage) @@ -89,4 +89,4 @@ if __name__ == '__main__': else: avg = 0 - print(f"InvenTree translation coverage: {avg}%") \ No newline at end of file + print(f"InvenTree translation coverage: {avg}%") diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 48db47ba0d..d302b1676c 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -1166,6 +1166,9 @@ class StockItem(MPTTModel): if self.belongs_to: raise ValidationError(_('Stock item is installed in another item')) + if self.installed_item_count() > 0: + raise ValidationError(_('Stock item contains other items')) + if self.customer: raise ValidationError(_('Stock item has been assigned to a customer'))