mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-11-04 07:05:41 +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,29 +628,31 @@ class SalesOrder(Order):
 | 
				
			|||||||
        Throws a ValidationError if cannot be completed.
 | 
					        Throws a ValidationError if cannot be completed.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            # Order without line items cannot be completed
 | 
					            # Order without line items cannot be completed
 | 
				
			||||||
            if self.lines.count() == 0:
 | 
					            if self.lines.count() == 0:
 | 
				
			||||||
            if raise_error:
 | 
					 | 
				
			||||||
                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
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if raise_error:
 | 
				
			||||||
 | 
					                raise e
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
                return False
 | 
					                return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def complete_order(self, user):
 | 
					    def complete_order(self, user):
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        Mark this order as "complete"
 | 
					        Mark this order as "complete"
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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,8 +54,11 @@ 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
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if verbose:
 | 
				
			||||||
        print("-" * 16)
 | 
					        print("-" * 16)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    percentages = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    for locale in locales.keys():
 | 
					    for locale in locales.keys():
 | 
				
			||||||
        locale_file = locales[locale]
 | 
					        locale_file = locales[locale]
 | 
				
			||||||
        stats = calculate_coverage(locale_file)
 | 
					        stats = calculate_coverage(locale_file)
 | 
				
			||||||
@@ -66,11 +70,23 @@ if __name__ == '__main__':
 | 
				
			|||||||
        else:
 | 
					        else:
 | 
				
			||||||
            percentage = 0
 | 
					            percentage = 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if verbose:
 | 
				
			||||||
            print(f"| {locale.ljust(4, ' ')} : {str(percentage).rjust(4, ' ')}% |")
 | 
					            print(f"| {locale.ljust(4, ' ')} : {str(percentage).rjust(4, ' ')}% |")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        locales_perc[locale] = percentage
 | 
					        locales_perc[locale] = percentage
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        percentages.append(percentage)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if verbose:
 | 
				
			||||||
        print("-" * 16)
 | 
					        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,7 +596,7 @@ 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}'>
 | 
				
			||||||
@@ -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