mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 13:15:43 +00:00 
			
		
		
		
	Merge pull request #2468 from SchrodingersGat/merge-stock-items
Merge stock items
This commit is contained in:
		| @@ -252,6 +252,9 @@ class StockHistoryCode(StatusCode): | |||||||
|     SPLIT_FROM_PARENT = 40 |     SPLIT_FROM_PARENT = 40 | ||||||
|     SPLIT_CHILD_ITEM = 42 |     SPLIT_CHILD_ITEM = 42 | ||||||
|  |  | ||||||
|  |     # Stock merging operations | ||||||
|  |     MERGED_STOCK_ITEMS = 45 | ||||||
|  |  | ||||||
|     # Build order codes |     # Build order codes | ||||||
|     BUILD_OUTPUT_CREATED = 50 |     BUILD_OUTPUT_CREATED = 50 | ||||||
|     BUILD_OUTPUT_COMPLETED = 55 |     BUILD_OUTPUT_COMPLETED = 55 | ||||||
| @@ -288,6 +291,8 @@ class StockHistoryCode(StatusCode): | |||||||
|         SPLIT_FROM_PARENT: _('Split from parent item'), |         SPLIT_FROM_PARENT: _('Split from parent item'), | ||||||
|         SPLIT_CHILD_ITEM: _('Split child item'), |         SPLIT_CHILD_ITEM: _('Split child item'), | ||||||
|  |  | ||||||
|  |         MERGED_STOCK_ITEMS: _('Merged stock items'), | ||||||
|  |  | ||||||
|         SENT_TO_CUSTOMER: _('Sent to customer'), |         SENT_TO_CUSTOMER: _('Sent to customer'), | ||||||
|         RETURNED_FROM_CUSTOMER: _('Returned from customer'), |         RETURNED_FROM_CUSTOMER: _('Returned from customer'), | ||||||
|  |  | ||||||
|   | |||||||
| @@ -12,10 +12,14 @@ import common.models | |||||||
| INVENTREE_SW_VERSION = "0.6.0 dev" | INVENTREE_SW_VERSION = "0.6.0 dev" | ||||||
|  |  | ||||||
| # InvenTree API version | # 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 | 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 | v21 -> 2021-12-04 | ||||||
|     - Adds support for multiple "Shipments" against a SalesOrder |     - Adds support for multiple "Shipments" against a SalesOrder | ||||||
|     - Refactors process for stock allocation against a SalesOrder |     - Refactors process for stock allocation against a SalesOrder | ||||||
|   | |||||||
| @@ -628,28 +628,30 @@ class SalesOrder(Order): | |||||||
|         Throws a ValidationError if cannot be completed. |         Throws a ValidationError if cannot be completed. | ||||||
|         """ |         """ | ||||||
|  |  | ||||||
|         # Order without line items cannot be completed |         try: | ||||||
|         if self.lines.count() == 0: |  | ||||||
|             if raise_error: |             # Order without line items cannot be completed | ||||||
|  |             if self.lines.count() == 0: | ||||||
|                 raise ValidationError(_('Order cannot be completed as no parts have been assigned')) |                 raise ValidationError(_('Order cannot be completed as no parts have been assigned')) | ||||||
|  |  | ||||||
|         # Only a PENDING order can be marked as SHIPPED |             # Only a PENDING order can be marked as SHIPPED | ||||||
|         elif self.status != SalesOrderStatus.PENDING: |             elif self.status != SalesOrderStatus.PENDING: | ||||||
|             if raise_error: |  | ||||||
|                 raise ValidationError(_('Only a pending order can be marked as complete')) |                 raise ValidationError(_('Only a pending order can be marked as complete')) | ||||||
|  |  | ||||||
|         elif self.pending_shipment_count > 0: |             elif self.pending_shipment_count > 0: | ||||||
|             if raise_error: |  | ||||||
|                 raise ValidationError(_("Order cannot be completed as there are incomplete shipments")) |                 raise ValidationError(_("Order cannot be completed as there are incomplete shipments")) | ||||||
|  |  | ||||||
|         elif self.pending_line_count > 0: |             elif self.pending_line_count > 0: | ||||||
|             if raise_error: |  | ||||||
|                 raise ValidationError(_("Order cannot be completed as there are incomplete line items")) |                 raise ValidationError(_("Order cannot be completed as there are incomplete line items")) | ||||||
|  |  | ||||||
|         else: |         except ValidationError as e: | ||||||
|             return True |  | ||||||
|  |  | ||||||
|         return False |             if raise_error: | ||||||
|  |                 raise e | ||||||
|  |             else: | ||||||
|  |                 return False | ||||||
|  |  | ||||||
|  |         return True | ||||||
|  |  | ||||||
|     def complete_order(self, user): |     def complete_order(self, user): | ||||||
|         """ |         """ | ||||||
|   | |||||||
| @@ -4,6 +4,7 @@ This script calculates translation coverage for various languages | |||||||
|  |  | ||||||
| import os | import os | ||||||
| import json | import json | ||||||
|  | import sys | ||||||
|  |  | ||||||
|  |  | ||||||
| def calculate_coverage(filename): | def calculate_coverage(filename): | ||||||
| @@ -42,7 +43,7 @@ if __name__ == '__main__': | |||||||
|     locales = {} |     locales = {} | ||||||
|     locales_perc = {} |     locales_perc = {} | ||||||
|  |  | ||||||
|     print("InvenTree translation coverage:") |     verbose = '-v' in sys.argv | ||||||
|  |  | ||||||
|     for locale in os.listdir(LC_DIR): |     for locale in os.listdir(LC_DIR): | ||||||
|         path = os.path.join(LC_DIR, locale) |         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): |             if os.path.exists(locale_file) and os.path.isfile(locale_file): | ||||||
|                 locales[locale] = locale_file |                 locales[locale] = locale_file | ||||||
|  |  | ||||||
|     print("-" * 16) |     if verbose: | ||||||
|  |         print("-" * 16) | ||||||
|  |  | ||||||
|  |     percentages = [] | ||||||
|  |  | ||||||
|     for locale in locales.keys(): |     for locale in locales.keys(): | ||||||
|         locale_file = locales[locale] |         locale_file = locales[locale] | ||||||
| @@ -66,11 +70,23 @@ if __name__ == '__main__': | |||||||
|         else: |         else: | ||||||
|             percentage = 0 |             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 |         locales_perc[locale] = percentage | ||||||
|  |  | ||||||
|     print("-" * 16) |         percentages.append(percentage) | ||||||
|  |  | ||||||
|  |     if verbose: | ||||||
|  |         print("-" * 16) | ||||||
|  |  | ||||||
|     # write locale stats |     # write locale stats | ||||||
|     with open(STAT_FILE, 'w') as target: |     with open(STAT_FILE, 'w') as target: | ||||||
|         json.dump(locales_perc, 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}%") | ||||||
|   | |||||||
| @@ -180,6 +180,20 @@ class StockAssign(generics.CreateAPIView): | |||||||
|         return ctx |         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): | class StockLocationList(generics.ListCreateAPIView): | ||||||
|     """ |     """ | ||||||
|     API endpoint for list view of StockLocation objects: |     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'^remove/', StockRemove.as_view(), name='api-stock-remove'), | ||||||
|     url(r'^transfer/', StockTransfer.as_view(), name='api-stock-transfer'), |     url(r'^transfer/', StockTransfer.as_view(), name='api-stock-transfer'), | ||||||
|     url(r'^assign/', StockAssign.as_view(), name='api-stock-assign'), |     url(r'^assign/', StockAssign.as_view(), name='api-stock-assign'), | ||||||
|  |     url(r'^merge/', StockMerge.as_view(), name='api-stock-merge'), | ||||||
|  |  | ||||||
|     # StockItemAttachment API endpoints |     # StockItemAttachment API endpoints | ||||||
|     url(r'^attachment/', include([ |     url(r'^attachment/', include([ | ||||||
|   | |||||||
| @@ -114,19 +114,6 @@ | |||||||
|     lft: 0 |     lft: 0 | ||||||
|     rght: 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 | - model: stock.stockitem | ||||||
|   pk: 501 |   pk: 501 | ||||||
|   fields: |   fields: | ||||||
|   | |||||||
| @@ -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'), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @@ -455,6 +455,7 @@ class StockItem(MPTTModel): | |||||||
|  |  | ||||||
|     uid = models.CharField(blank=True, max_length=128, help_text=("Unique identifier field")) |     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( |     parent = TreeForeignKey( | ||||||
|         'self', |         'self', | ||||||
|         verbose_name=_('Parent Stock Item'), |         verbose_name=_('Parent Stock Item'), | ||||||
| @@ -477,6 +478,7 @@ class StockItem(MPTTModel): | |||||||
|         help_text=_('Select a matching supplier part for this stock item') |         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( |     location = TreeForeignKey( | ||||||
|         StockLocation, on_delete=models.DO_NOTHING, |         StockLocation, on_delete=models.DO_NOTHING, | ||||||
|         verbose_name=_('Stock Location'), |         verbose_name=_('Stock Location'), | ||||||
| @@ -492,10 +494,11 @@ class StockItem(MPTTModel): | |||||||
|         help_text=_('Packaging this stock item is stored in') |         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( |     belongs_to = models.ForeignKey( | ||||||
|         'self', |         'self', | ||||||
|         verbose_name=_('Installed In'), |         verbose_name=_('Installed In'), | ||||||
|         on_delete=models.DO_NOTHING, |         on_delete=models.CASCADE, | ||||||
|         related_name='installed_parts', blank=True, null=True, |         related_name='installed_parts', blank=True, null=True, | ||||||
|         help_text=_('Is this item installed in another item?') |         help_text=_('Is this item installed in another item?') | ||||||
|     ) |     ) | ||||||
| @@ -800,14 +803,14 @@ class StockItem(MPTTModel): | |||||||
|     def can_delete(self): |     def can_delete(self): | ||||||
|         """ Can this stock item be deleted? It can NOT be deleted under the following circumstances: |         """ 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 |         - Has a serial number and is tracked | ||||||
|         - Is installed inside another StockItem |         - Is installed inside another StockItem | ||||||
|         - It has been assigned to a SalesOrder |         - It has been assigned to a SalesOrder | ||||||
|         - It has been assigned to a BuildOrder |         - It has been assigned to a BuildOrder | ||||||
|         """ |         """ | ||||||
|  |  | ||||||
|         if self.child_count > 0: |         if self.installed_item_count() > 0: | ||||||
|             return False |             return False | ||||||
|  |  | ||||||
|         if self.part.trackable and self.serial is not None: |         if self.part.trackable and self.serial is not None: | ||||||
| @@ -853,20 +856,13 @@ class StockItem(MPTTModel): | |||||||
|  |  | ||||||
|         return installed |         return installed | ||||||
|  |  | ||||||
|     def installedItemCount(self): |     def installed_item_count(self): | ||||||
|         """ |         """ | ||||||
|         Return the number of stock items installed inside this one. |         Return the number of stock items installed inside this one. | ||||||
|         """ |         """ | ||||||
|  |  | ||||||
|         return self.installed_parts.count() |         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 |     @transaction.atomic | ||||||
|     def installStockItem(self, other_item, quantity, user, notes): |     def installStockItem(self, other_item, quantity, user, notes): | ||||||
|         """ |         """ | ||||||
| @@ -1153,6 +1149,124 @@ class StockItem(MPTTModel): | |||||||
|             result.stock_item = self |             result.stock_item = self | ||||||
|             result.save() |             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 |     @transaction.atomic | ||||||
|     def splitStock(self, quantity, location, user, **kwargs): |     def splitStock(self, quantity, location, user, **kwargs): | ||||||
|         """ Split this stock item into two items, in the same location. |         """ 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') | @receiver(pre_delete, sender=StockItem, dispatch_uid='stock_item_pre_delete_log') | ||||||
| def before_delete_stock_item(sender, instance, using, **kwargs): | 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, |     Before a StockItem is deleted, ensure that each child object is updated, | ||||||
|     to point to the new parent item. |     to point to the new parent item. | ||||||
|   | |||||||
| @@ -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): | class StockAdjustmentItemSerializer(serializers.Serializer): | ||||||
|     """ |     """ | ||||||
|     Serializer for a single StockItem within a stock adjument request. |     Serializer for a single StockItem within a stock adjument request. | ||||||
| @@ -837,7 +980,7 @@ class StockTransferSerializer(StockAdjustmentSerializer): | |||||||
|  |  | ||||||
|     def validate(self, data): |     def validate(self, data): | ||||||
|  |  | ||||||
|         super().validate(data) |         data = super().validate(data) | ||||||
|  |  | ||||||
|         # TODO: Any specific validation of location field? |         # TODO: Any specific validation of location field? | ||||||
|  |  | ||||||
|   | |||||||
| @@ -274,14 +274,6 @@ | |||||||
|     <div class='alert alert-block alert-warning'> |     <div class='alert alert-block alert-warning'> | ||||||
|         {% trans "This stock item is serialized - it has a unique serial number and the quantity cannot be adjusted." %} |         {% trans "This stock item is serialized - it has a unique serial number and the quantity cannot be adjusted." %} | ||||||
|     </div> |     </div> | ||||||
|     {% elif item.child_count > 0 %} |  | ||||||
|     <div class='alert alert-block alert-warning'> |  | ||||||
|         {% trans "This stock item cannot be deleted as it has child items" %} |  | ||||||
|     </div> |  | ||||||
|     {% elif item.delete_on_deplete and item.can_delete %} |  | ||||||
|     <div class='alert alert-block alert-warning'> |  | ||||||
|         {% trans "This stock item will be automatically deleted when all stock is depleted." %} |  | ||||||
|     </div> |  | ||||||
|     {% endif %} |     {% endif %} | ||||||
|      |      | ||||||
| </div> | </div> | ||||||
|   | |||||||
| @@ -842,3 +842,189 @@ class StockAssignTest(StockAPITestCase): | |||||||
|  |  | ||||||
|         # 5 stock items should now have been assigned to this customer |         # 5 stock items should now have been assigned to this customer | ||||||
|         self.assertEqual(customer.assigned_stock.count(), 5) |         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) | ||||||
|   | |||||||
| @@ -52,6 +52,7 @@ | |||||||
|     loadStockTestResultsTable, |     loadStockTestResultsTable, | ||||||
|     loadStockTrackingTable, |     loadStockTrackingTable, | ||||||
|     loadTableFilters, |     loadTableFilters, | ||||||
|  |     mergeStockItems, | ||||||
|     removeStockRow, |     removeStockRow, | ||||||
|     serializeStockItem, |     serializeStockItem, | ||||||
|     stockItemFields, |     stockItemFields, | ||||||
| @@ -595,17 +596,17 @@ function assignStockToCustomer(items, options={}) { | |||||||
|         buttons += '</div>'; |         buttons += '</div>'; | ||||||
|  |  | ||||||
|         html += ` |         html += ` | ||||||
|             <tr id='stock_item_${pk}' class='stock-item'row'> |         <tr id='stock_item_${pk}' class='stock-item-row'> | ||||||
|                 <td id='part_${pk}'>${thumbnail} ${part.full_name}</td> |             <td id='part_${pk}'>${thumbnail} ${part.full_name}</td> | ||||||
|                 <td id='stock_${pk}'> |             <td id='stock_${pk}'> | ||||||
|                     <div id='div_id_items_item_${pk}'> |                 <div id='div_id_items_item_${pk}'> | ||||||
|                         ${quantity} |                     ${quantity} | ||||||
|                         <div id='errors-items_item_${pk}'></div> |                     <div id='errors-items_item_${pk}'></div> | ||||||
|                     </div> |                 </div> | ||||||
|                 </td> |             </td> | ||||||
|                 <td id='location_${pk}'>${location}</td> |             <td id='location_${pk}'>${location}</td> | ||||||
|                 <td id='buttons_${pk}'>${buttons}</td> |             <td id='buttons_${pk}'>${buttons}</td> | ||||||
|             </tr> |         </tr> | ||||||
|         `; |         `; | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -615,13 +616,13 @@ function assignStockToCustomer(items, options={}) { | |||||||
|         method: 'POST', |         method: 'POST', | ||||||
|         preFormContent: html, |         preFormContent: html, | ||||||
|         fields: { |         fields: { | ||||||
|             'customer': { |             customer: { | ||||||
|                 value: options.customer, |                 value: options.customer, | ||||||
|                 filters: { |                 filters: { | ||||||
|                     is_customer: true, |                     is_customer: true, | ||||||
|                 }, |                 }, | ||||||
|             }, |             }, | ||||||
|             'notes': {}, |             notes: {}, | ||||||
|         }, |         }, | ||||||
|         confirm: true, |         confirm: true, | ||||||
|         confirmMessage: '{% trans "Confirm stock assignment" %}', |         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 = ` | ||||||
|  |     <div class='alert alert-block alert-danger'> | ||||||
|  |     <h5>{% trans "Warning: Merge operation cannot be reversed" %}</h5> | ||||||
|  |     <strong>{% trans "Some information will be lost when merging stock items" %}:</strong> | ||||||
|  |     <ul> | ||||||
|  |         <li>{% trans "Stock transaction history will be deleted for merged items" %}</li> | ||||||
|  |         <li>{% trans "Supplier part information will be deleted for merged items" %}</li> | ||||||
|  |     </ul> | ||||||
|  |     </div> | ||||||
|  |     `; | ||||||
|  |  | ||||||
|  |     html += ` | ||||||
|  |     <table class='table table-striped table-condensed' id='stock-merge-table'> | ||||||
|  |     <thead> | ||||||
|  |         <tr> | ||||||
|  |             <th>{% trans "Part" %}</th> | ||||||
|  |             <th>{% trans "Stock Item" %}</th> | ||||||
|  |             <th>{% trans "Location" %}</th> | ||||||
|  |             <th></th> | ||||||
|  |         </tr> | ||||||
|  |     </thead> | ||||||
|  |     <tbody> | ||||||
|  |     `; | ||||||
|  |  | ||||||
|  |     // 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 = `<div class='btn-group' role='group'>`; | ||||||
|  |  | ||||||
|  |         buttons += makeIconButton( | ||||||
|  |             'fa-times icon-red', | ||||||
|  |             'button-stock-item-remove', | ||||||
|  |             pk, | ||||||
|  |             '{% trans "Remove row" %}', | ||||||
|  |         ); | ||||||
|  |  | ||||||
|  |         html += ` | ||||||
|  |         <tr id='stock_item_${pk}' class='stock-item-row'> | ||||||
|  |             <td id='part_${pk}'>${thumbnail} ${part.full_name}</td> | ||||||
|  |             <td id='stock_${pk}'> | ||||||
|  |                 <div id='div_id_items_item_${pk}'> | ||||||
|  |                     ${quantity} | ||||||
|  |                     <div id='errors-items_item_${pk}'></div> | ||||||
|  |                 </div> | ||||||
|  |             </td> | ||||||
|  |             <td id='location_${pk}'>${location}</td> | ||||||
|  |             <td id='buttons_${pk}'>${buttons}</td> | ||||||
|  |         </tr> | ||||||
|  |         `; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     html += '</tbody></table>'; | ||||||
|  |  | ||||||
|  |     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 |  * Perform stock adjustments | ||||||
|  */ |  */ | ||||||
| @@ -1875,6 +2054,20 @@ function loadStockTable(table, options) { | |||||||
|         stockAdjustment('move'); |         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() { |     $('#multi-item-assign').click(function() { | ||||||
|  |  | ||||||
|         var items = $(table).bootstrapTable('getSelections'); |         var items = $(table).bootstrapTable('getSelections'); | ||||||
|   | |||||||
| @@ -49,6 +49,7 @@ | |||||||
|                     <li><a class='dropdown-item' href="#" id='multi-item-remove' title='{% trans "Remove from selected stock items" %}'><span class='fas fa-minus-circle'></span> {% trans "Remove stock" %}</a></li> |                     <li><a class='dropdown-item' href="#" id='multi-item-remove' title='{% trans "Remove from selected stock items" %}'><span class='fas fa-minus-circle'></span> {% trans "Remove stock" %}</a></li> | ||||||
|                     <li><a class='dropdown-item' href="#" id='multi-item-stocktake' title='{% trans "Stocktake selected stock items" %}'><span class='fas fa-check-circle'></span> {% trans "Count stock" %}</a></li> |                     <li><a class='dropdown-item' href="#" id='multi-item-stocktake' title='{% trans "Stocktake selected stock items" %}'><span class='fas fa-check-circle'></span> {% trans "Count stock" %}</a></li> | ||||||
|                     <li><a class='dropdown-item' href='#' id='multi-item-move' title='{% trans "Move selected stock items" %}'><span class='fas fa-exchange-alt'></span> {% trans "Move stock" %}</a></li> |                     <li><a class='dropdown-item' href='#' id='multi-item-move' title='{% trans "Move selected stock items" %}'><span class='fas fa-exchange-alt'></span> {% trans "Move stock" %}</a></li> | ||||||
|  |                     <li><a class='dropdown-item' href='#' id='multi-item-merge' title='{% trans "Merge selected stock items" %}'><span class='fas fa-object-group'></span> {% trans "Merge stock" %}</a></li> | ||||||
|                     <li><a class='dropdown-item' href='#' id='multi-item-order' title='{% trans "Order selected items" %}'><span class='fas fa-shopping-cart'></span> {% trans "Order stock" %}</a></li> |                     <li><a class='dropdown-item' href='#' id='multi-item-order' title='{% trans "Order selected items" %}'><span class='fas fa-shopping-cart'></span> {% trans "Order stock" %}</a></li> | ||||||
|                     <li><a class='dropdown-item' href='#' id='multi-item-assign' title='{% trans "Assign to customer" %}'><span class='fas fa-user-tie'></span> {% trans "Assign to customer" %}</a></li> |                     <li><a class='dropdown-item' href='#' id='multi-item-assign' title='{% trans "Assign to customer" %}'><span class='fas fa-user-tie'></span> {% trans "Assign to customer" %}</a></li> | ||||||
|                     <li><a class='dropdown-item' href='#' id='multi-item-set-status' title='{% trans "Change status" %}'><span class='fas fa-exclamation-circle'></span> {% trans "Change stock status" %}</a></li> |                     <li><a class='dropdown-item' href='#' id='multi-item-set-status' title='{% trans "Change status" %}'><span class='fas fa-exclamation-circle'></span> {% trans "Change stock status" %}</a></li> | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user