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/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 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/script/translation_stats.py b/InvenTree/script/translation_stats.py index c6126f2944..4ee83120eb 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}%") 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/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/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/models.py b/InvenTree/stock/models.py index 7f385e7136..d302b1676c 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -455,6 +455,7 @@ 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'), @@ -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): """ @@ -1153,6 +1149,124 @@ class StockItem(MPTTModel): result.stock_item = self result.save() + def can_merge(self, other=None, raise_error=False, **kwargs): + """ + 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: + # 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.installed_item_count() > 0: + raise ValidationError(_('Stock item contains other items')) + + 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 + else: + return False + + return True + + @transaction.atomic + def merge_stock_items(self, other_items, raise_error=False, **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 len(other_items) == 0: + return + + user = kwargs.get('user', None) + 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): + 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() + + # 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( + StockHistoryCode.MERGED_STOCK_ITEMS, + user, + quantity=self.quantity, + notes=notes, + deltas={ + 'location': location.pk, + } + ) + + self.location = location + self.save() + @transaction.atomic def splitStock(self, quantity, location, user, **kwargs): """ Split this stock item into two items, in the same location. @@ -1648,7 +1762,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/serializers.py b/InvenTree/stock/serializers.py index e69cd90f82..681ead9caf 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -674,6 +674,149 @@ 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 + """ + + class Meta: + fields = [ + 'items', + 'location', + 'notes', + 'allow_mismatched_suppliers', + 'allow_mismatched_status', + ] + + items = StockMergeItemSerializer( + many=True, + required=True, + ) + + location = serializers.PrimaryKeyRelatedField( + queryset=StockLocation.objects.all(), + many=False, + required=True, + allow_null=False, + label=_('Location'), + 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) + + items = data['items'] + + 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): + """ + 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): """ Serializer for a single StockItem within a stock adjument request. @@ -837,7 +980,7 @@ class StockTransferSerializer(StockAdjustmentSerializer): def validate(self, data): - super().validate(data) + data = super().validate(data) # TODO: Any specific validation of location field? 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 "Part" %} | +{% trans "Stock Item" %} | +{% trans "Location" %} | ++ |
---|---|---|---|
${thumbnail} ${part.full_name} | +
+
+ ${quantity}
+
+
+ |
+ ${location} | +${buttons} | +