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)