mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-11-03 22:55:43 +00:00 
			
		
		
		
	Sales order improvements (#8445)
* Migration for SalesOrderAllocation - Allow allocation against order with null shipment * Enhaced query efficiency * Further API cleanup * Adjust serializer * PUI updates * Enable editing of allocation shipment * Improve shipment filtering * Add sub-table for salesorderlineitem * Add helper method to SalesOrder to return pending SalesOrderAllocations * Fix for CUI * Update form for CUI * Prevent SalesOrder completion with incomplete allocations * Fixes for StockItem API * Frontend refactoring * Code cleanup * Annotate shipment information to SalesOrder API endpoint * Update frontend PUI * Additional filtering for SalesOrderAllocation * Bump API version * Hide panel based on user permissions * js linting * Unit test fix * Update playwright tests * Revert diff * Disable playwright test (temporary) * View output from build table
This commit is contained in:
		@@ -1,13 +1,19 @@
 | 
				
			|||||||
"""InvenTree API version information."""
 | 
					"""InvenTree API version information."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# InvenTree API version
 | 
					# InvenTree API version
 | 
				
			||||||
INVENTREE_API_VERSION = 277
 | 
					INVENTREE_API_VERSION = 278
 | 
				
			||||||
 | 
					
 | 
				
			||||||
"""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."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
INVENTREE_API_TEXT = """
 | 
					INVENTREE_API_TEXT = """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					v278 - 2024-11-07 : https://github.com/inventree/InvenTree/pull/8445
 | 
				
			||||||
 | 
					    - Updates to the SalesOrder API endpoints
 | 
				
			||||||
 | 
					    - Add "shipment count" information to the SalesOrder API endpoints
 | 
				
			||||||
 | 
					    - Allow null value for SalesOrderAllocation.shipment field
 | 
				
			||||||
 | 
					    - Additional filtering options for allocation endpoints
 | 
				
			||||||
 | 
					
 | 
				
			||||||
v277 - 2024-11-01 : https://github.com/inventree/InvenTree/pull/8278
 | 
					v277 - 2024-11-01 : https://github.com/inventree/InvenTree/pull/8278
 | 
				
			||||||
    - Allow build order list to be filtered by "outstanding" (alias for "active")
 | 
					    - Allow build order list to be filtered by "outstanding" (alias for "active")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -978,7 +978,7 @@ class SalesOrderAllocationFilter(rest_filters.FilterSet):
 | 
				
			|||||||
        """Metaclass options."""
 | 
					        """Metaclass options."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        model = models.SalesOrderAllocation
 | 
					        model = models.SalesOrderAllocation
 | 
				
			||||||
        fields = ['shipment', 'item']
 | 
					        fields = ['shipment', 'line', 'item']
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    order = rest_filters.ModelChoiceFilter(
 | 
					    order = rest_filters.ModelChoiceFilter(
 | 
				
			||||||
        queryset=models.SalesOrder.objects.all(),
 | 
					        queryset=models.SalesOrder.objects.all(),
 | 
				
			||||||
@@ -1034,6 +1034,16 @@ class SalesOrderAllocationFilter(rest_filters.FilterSet):
 | 
				
			|||||||
            line__order__status__in=SalesOrderStatusGroups.OPEN,
 | 
					            line__order__status__in=SalesOrderStatusGroups.OPEN,
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    assigned_to_shipment = rest_filters.BooleanFilter(
 | 
				
			||||||
 | 
					        label=_('Has Shipment'), method='filter_assigned_to_shipment'
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def filter_assigned_to_shipment(self, queryset, name, value):
 | 
				
			||||||
 | 
					        """Filter by whether or not the allocation has been assigned to a shipment."""
 | 
				
			||||||
 | 
					        if str2bool(value):
 | 
				
			||||||
 | 
					            return queryset.exclude(shipment=None)
 | 
				
			||||||
 | 
					        return queryset.filter(shipment=None)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class SalesOrderAllocationMixin:
 | 
					class SalesOrderAllocationMixin:
 | 
				
			||||||
    """Mixin class for SalesOrderAllocation endpoints."""
 | 
					    """Mixin class for SalesOrderAllocation endpoints."""
 | 
				
			||||||
@@ -1049,12 +1059,16 @@ class SalesOrderAllocationMixin:
 | 
				
			|||||||
            'item',
 | 
					            'item',
 | 
				
			||||||
            'item__sales_order',
 | 
					            'item__sales_order',
 | 
				
			||||||
            'item__part',
 | 
					            'item__part',
 | 
				
			||||||
 | 
					            'line__part',
 | 
				
			||||||
            'item__location',
 | 
					            'item__location',
 | 
				
			||||||
            'line__order',
 | 
					            'line__order',
 | 
				
			||||||
            'line__part',
 | 
					            'line__order__responsible',
 | 
				
			||||||
 | 
					            'line__order__project_code',
 | 
				
			||||||
 | 
					            'line__order__project_code__responsible',
 | 
				
			||||||
            'shipment',
 | 
					            'shipment',
 | 
				
			||||||
            'shipment__order',
 | 
					            'shipment__order',
 | 
				
			||||||
        )
 | 
					            'shipment__checked_by',
 | 
				
			||||||
 | 
					        ).select_related('line__part__pricing_data', 'item__part__pricing_data')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return queryset
 | 
					        return queryset
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -1065,7 +1079,15 @@ class SalesOrderAllocationList(SalesOrderAllocationMixin, ListAPI):
 | 
				
			|||||||
    filterset_class = SalesOrderAllocationFilter
 | 
					    filterset_class = SalesOrderAllocationFilter
 | 
				
			||||||
    filter_backends = SEARCH_ORDER_FILTER_ALIAS
 | 
					    filter_backends = SEARCH_ORDER_FILTER_ALIAS
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    ordering_fields = ['quantity', 'part', 'serial', 'batch', 'location', 'order']
 | 
					    ordering_fields = [
 | 
				
			||||||
 | 
					        'quantity',
 | 
				
			||||||
 | 
					        'part',
 | 
				
			||||||
 | 
					        'serial',
 | 
				
			||||||
 | 
					        'batch',
 | 
				
			||||||
 | 
					        'location',
 | 
				
			||||||
 | 
					        'order',
 | 
				
			||||||
 | 
					        'shipment_date',
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    ordering_field_aliases = {
 | 
					    ordering_field_aliases = {
 | 
				
			||||||
        'part': 'item__part__name',
 | 
					        'part': 'item__part__name',
 | 
				
			||||||
@@ -1073,6 +1095,7 @@ class SalesOrderAllocationList(SalesOrderAllocationMixin, ListAPI):
 | 
				
			|||||||
        'batch': 'item__batch',
 | 
					        'batch': 'item__batch',
 | 
				
			||||||
        'location': 'item__location__name',
 | 
					        'location': 'item__location__name',
 | 
				
			||||||
        'order': 'line__order__reference',
 | 
					        'order': 'line__order__reference',
 | 
				
			||||||
 | 
					        'shipment_date': 'shipment__shipment_date',
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    search_fields = {'item__part__name', 'item__serial', 'item__batch'}
 | 
					    search_fields = {'item__part__name', 'item__serial', 'item__batch'}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -0,0 +1,19 @@
 | 
				
			|||||||
 | 
					# Generated by Django 4.2.16 on 2024-11-06 04:46
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					import django.db.models.deletion
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ('order', '0102_purchaseorder_destination_and_more'),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.AlterField(
 | 
				
			||||||
 | 
					            model_name='salesorderallocation',
 | 
				
			||||||
 | 
					            name='shipment',
 | 
				
			||||||
 | 
					            field=models.ForeignKey(blank=True, help_text='Sales order shipment reference', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='allocations', to='order.salesordershipment', verbose_name='Shipment'),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
@@ -1105,6 +1105,11 @@ class SalesOrder(TotalPriceMixin, Order):
 | 
				
			|||||||
                    _('Order cannot be completed as there are incomplete shipments')
 | 
					                    _('Order cannot be completed as there are incomplete shipments')
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if self.pending_allocation_count > 0:
 | 
				
			||||||
 | 
					                raise ValidationError(
 | 
				
			||||||
 | 
					                    _('Order cannot be completed as there are incomplete allocations')
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if not allow_incomplete_lines and self.pending_line_count > 0:
 | 
					            if not allow_incomplete_lines and self.pending_line_count > 0:
 | 
				
			||||||
                raise ValidationError(
 | 
					                raise ValidationError(
 | 
				
			||||||
                    _('Order cannot be completed as there are incomplete line items')
 | 
					                    _('Order cannot be completed as there are incomplete line items')
 | 
				
			||||||
@@ -1297,6 +1302,23 @@ class SalesOrder(TotalPriceMixin, Order):
 | 
				
			|||||||
        """Return a queryset of the pending shipments for this order."""
 | 
					        """Return a queryset of the pending shipments for this order."""
 | 
				
			||||||
        return self.shipments.filter(shipment_date=None)
 | 
					        return self.shipments.filter(shipment_date=None)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def allocations(self):
 | 
				
			||||||
 | 
					        """Return a queryset of all allocations for this order."""
 | 
				
			||||||
 | 
					        return SalesOrderAllocation.objects.filter(line__order=self)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def pending_allocations(self):
 | 
				
			||||||
 | 
					        """Return a queryset of any pending allocations for this order.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Allocations are pending if:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        a) They are not associated with a SalesOrderShipment
 | 
				
			||||||
 | 
					        b) The linked SalesOrderShipment has not been shipped
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Q1 = Q(shipment=None)
 | 
				
			||||||
 | 
					        Q2 = Q(shipment__shipment_date=None)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return self.allocations().filter(Q1 | Q2).distinct()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def shipment_count(self):
 | 
					    def shipment_count(self):
 | 
				
			||||||
        """Return the total number of shipments associated with this order."""
 | 
					        """Return the total number of shipments associated with this order."""
 | 
				
			||||||
@@ -1312,6 +1334,11 @@ class SalesOrder(TotalPriceMixin, Order):
 | 
				
			|||||||
        """Return the number of pending shipments associated with this order."""
 | 
					        """Return the number of pending shipments associated with this order."""
 | 
				
			||||||
        return self.pending_shipments().count()
 | 
					        return self.pending_shipments().count()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def pending_allocation_count(self):
 | 
				
			||||||
 | 
					        """Return the number of pending (non-shipped) allocations."""
 | 
				
			||||||
 | 
					        return self.pending_allocations().count()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@receiver(post_save, sender=SalesOrder, dispatch_uid='sales_order_post_save')
 | 
					@receiver(post_save, sender=SalesOrder, dispatch_uid='sales_order_post_save')
 | 
				
			||||||
def after_save_sales_order(sender, instance: SalesOrder, created: bool, **kwargs):
 | 
					def after_save_sales_order(sender, instance: SalesOrder, created: bool, **kwargs):
 | 
				
			||||||
@@ -2030,7 +2057,7 @@ class SalesOrderAllocation(models.Model):
 | 
				
			|||||||
        if self.item.serial and self.quantity != 1:
 | 
					        if self.item.serial and self.quantity != 1:
 | 
				
			||||||
            errors['quantity'] = _('Quantity must be 1 for serialized stock item')
 | 
					            errors['quantity'] = _('Quantity must be 1 for serialized stock item')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if self.line.order != self.shipment.order:
 | 
					        if self.shipment and self.line.order != self.shipment.order:
 | 
				
			||||||
            errors['line'] = _('Sales order does not match shipment')
 | 
					            errors['line'] = _('Sales order does not match shipment')
 | 
				
			||||||
            errors['shipment'] = _('Shipment does not match sales order')
 | 
					            errors['shipment'] = _('Shipment does not match sales order')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -2047,6 +2074,8 @@ class SalesOrderAllocation(models.Model):
 | 
				
			|||||||
    shipment = models.ForeignKey(
 | 
					    shipment = models.ForeignKey(
 | 
				
			||||||
        SalesOrderShipment,
 | 
					        SalesOrderShipment,
 | 
				
			||||||
        on_delete=models.CASCADE,
 | 
					        on_delete=models.CASCADE,
 | 
				
			||||||
 | 
					        null=True,
 | 
				
			||||||
 | 
					        blank=True,
 | 
				
			||||||
        related_name='allocations',
 | 
					        related_name='allocations',
 | 
				
			||||||
        verbose_name=_('Shipment'),
 | 
					        verbose_name=_('Shipment'),
 | 
				
			||||||
        help_text=_('Sales order shipment reference'),
 | 
					        help_text=_('Sales order shipment reference'),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -990,6 +990,8 @@ class SalesOrderSerializer(
 | 
				
			|||||||
            'shipment_date',
 | 
					            'shipment_date',
 | 
				
			||||||
            'total_price',
 | 
					            'total_price',
 | 
				
			||||||
            'order_currency',
 | 
					            'order_currency',
 | 
				
			||||||
 | 
					            'shipments_count',
 | 
				
			||||||
 | 
					            'completed_shipments_count',
 | 
				
			||||||
        ])
 | 
					        ])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        read_only_fields = ['status', 'creation_date', 'shipment_date']
 | 
					        read_only_fields = ['status', 'creation_date', 'shipment_date']
 | 
				
			||||||
@@ -1035,12 +1037,26 @@ class SalesOrderSerializer(
 | 
				
			|||||||
            )
 | 
					            )
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Annotate shipment details
 | 
				
			||||||
 | 
					        queryset = queryset.annotate(
 | 
				
			||||||
 | 
					            shipments_count=SubqueryCount('shipments'),
 | 
				
			||||||
 | 
					            completed_shipments_count=SubqueryCount(
 | 
				
			||||||
 | 
					                'shipments', filter=Q(shipment_date__isnull=False)
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return queryset
 | 
					        return queryset
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    customer_detail = CompanyBriefSerializer(
 | 
					    customer_detail = CompanyBriefSerializer(
 | 
				
			||||||
        source='customer', many=False, read_only=True
 | 
					        source='customer', many=False, read_only=True
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    shipments_count = serializers.IntegerField(read_only=True, label=_('Shipments'))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    completed_shipments_count = serializers.IntegerField(
 | 
				
			||||||
 | 
					        read_only=True, label=_('Completed Shipments')
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class SalesOrderIssueSerializer(OrderAdjustSerializer):
 | 
					class SalesOrderIssueSerializer(OrderAdjustSerializer):
 | 
				
			||||||
    """Serializer for issuing a SalesOrder."""
 | 
					    """Serializer for issuing a SalesOrder."""
 | 
				
			||||||
@@ -1246,6 +1262,15 @@ class SalesOrderShipmentSerializer(NotesFieldMixin, InvenTreeModelSerializer):
 | 
				
			|||||||
            'notes',
 | 
					            'notes',
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, *args, **kwargs):
 | 
				
			||||||
 | 
					        """Initialization routine for the serializer."""
 | 
				
			||||||
 | 
					        order_detail = kwargs.pop('order_detail', True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        super().__init__(*args, **kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if not order_detail:
 | 
				
			||||||
 | 
					            self.fields.pop('order_detail', None)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @staticmethod
 | 
					    @staticmethod
 | 
				
			||||||
    def annotate_queryset(queryset):
 | 
					    def annotate_queryset(queryset):
 | 
				
			||||||
        """Annotate the queryset with extra information."""
 | 
					        """Annotate the queryset with extra information."""
 | 
				
			||||||
@@ -1276,22 +1301,26 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        fields = [
 | 
					        fields = [
 | 
				
			||||||
            'pk',
 | 
					            'pk',
 | 
				
			||||||
            'line',
 | 
					 | 
				
			||||||
            'customer_detail',
 | 
					 | 
				
			||||||
            'serial',
 | 
					 | 
				
			||||||
            'quantity',
 | 
					 | 
				
			||||||
            'location',
 | 
					 | 
				
			||||||
            'location_detail',
 | 
					 | 
				
			||||||
            'item',
 | 
					            'item',
 | 
				
			||||||
            'item_detail',
 | 
					            'quantity',
 | 
				
			||||||
            'order',
 | 
					 | 
				
			||||||
            'order_detail',
 | 
					 | 
				
			||||||
            'part',
 | 
					 | 
				
			||||||
            'part_detail',
 | 
					 | 
				
			||||||
            'shipment',
 | 
					            'shipment',
 | 
				
			||||||
 | 
					            # Annotated read-only fields
 | 
				
			||||||
 | 
					            'line',
 | 
				
			||||||
 | 
					            'part',
 | 
				
			||||||
 | 
					            'order',
 | 
				
			||||||
 | 
					            'serial',
 | 
				
			||||||
 | 
					            'location',
 | 
				
			||||||
 | 
					            # Extra detail fields
 | 
				
			||||||
 | 
					            'item_detail',
 | 
				
			||||||
 | 
					            'part_detail',
 | 
				
			||||||
 | 
					            'order_detail',
 | 
				
			||||||
 | 
					            'customer_detail',
 | 
				
			||||||
 | 
					            'location_detail',
 | 
				
			||||||
            'shipment_detail',
 | 
					            'shipment_detail',
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        read_only_fields = ['line', '']
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __init__(self, *args, **kwargs):
 | 
					    def __init__(self, *args, **kwargs):
 | 
				
			||||||
        """Initialization routine for the serializer."""
 | 
					        """Initialization routine for the serializer."""
 | 
				
			||||||
        order_detail = kwargs.pop('order_detail', False)
 | 
					        order_detail = kwargs.pop('order_detail', False)
 | 
				
			||||||
@@ -1341,7 +1370,7 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
 | 
				
			|||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    shipment_detail = SalesOrderShipmentSerializer(
 | 
					    shipment_detail = SalesOrderShipmentSerializer(
 | 
				
			||||||
        source='shipment', many=False, read_only=True
 | 
					        source='shipment', order_detail=False, many=False, read_only=True
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -1596,8 +1625,8 @@ class SalesOrderSerialAllocationSerializer(serializers.Serializer):
 | 
				
			|||||||
    shipment = serializers.PrimaryKeyRelatedField(
 | 
					    shipment = serializers.PrimaryKeyRelatedField(
 | 
				
			||||||
        queryset=order.models.SalesOrderShipment.objects.all(),
 | 
					        queryset=order.models.SalesOrderShipment.objects.all(),
 | 
				
			||||||
        many=False,
 | 
					        many=False,
 | 
				
			||||||
        allow_null=False,
 | 
					        required=False,
 | 
				
			||||||
        required=True,
 | 
					        allow_null=True,
 | 
				
			||||||
        label=_('Shipment'),
 | 
					        label=_('Shipment'),
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -1609,10 +1638,10 @@ class SalesOrderSerialAllocationSerializer(serializers.Serializer):
 | 
				
			|||||||
        """
 | 
					        """
 | 
				
			||||||
        order = self.context['order']
 | 
					        order = self.context['order']
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if shipment.shipment_date is not None:
 | 
					        if shipment and shipment.shipment_date is not None:
 | 
				
			||||||
            raise ValidationError(_('Shipment has already been shipped'))
 | 
					            raise ValidationError(_('Shipment has already been shipped'))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if shipment.order != order:
 | 
					        if shipment and shipment.order != order:
 | 
				
			||||||
            raise ValidationError(_('Shipment is not associated with this order'))
 | 
					            raise ValidationError(_('Shipment is not associated with this order'))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return shipment
 | 
					        return shipment
 | 
				
			||||||
@@ -1720,8 +1749,8 @@ class SalesOrderShipmentAllocationSerializer(serializers.Serializer):
 | 
				
			|||||||
    shipment = serializers.PrimaryKeyRelatedField(
 | 
					    shipment = serializers.PrimaryKeyRelatedField(
 | 
				
			||||||
        queryset=order.models.SalesOrderShipment.objects.all(),
 | 
					        queryset=order.models.SalesOrderShipment.objects.all(),
 | 
				
			||||||
        many=False,
 | 
					        many=False,
 | 
				
			||||||
        allow_null=False,
 | 
					        required=False,
 | 
				
			||||||
        required=True,
 | 
					        allow_null=True,
 | 
				
			||||||
        label=_('Shipment'),
 | 
					        label=_('Shipment'),
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -1756,7 +1785,7 @@ class SalesOrderShipmentAllocationSerializer(serializers.Serializer):
 | 
				
			|||||||
        data = self.validated_data
 | 
					        data = self.validated_data
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        items = data['items']
 | 
					        items = data['items']
 | 
				
			||||||
        shipment = data['shipment']
 | 
					        shipment = data.get('shipment')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        with transaction.atomic():
 | 
					        with transaction.atomic():
 | 
				
			||||||
            for entry in items:
 | 
					            for entry in items:
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1877,7 +1877,6 @@ class SalesOrderAllocateTest(OrderTest):
 | 
				
			|||||||
        response = self.post(self.url, {}, expected_code=400)
 | 
					        response = self.post(self.url, {}, expected_code=400)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.assertIn('This field is required', str(response.data['items']))
 | 
					        self.assertIn('This field is required', str(response.data['items']))
 | 
				
			||||||
        self.assertIn('This field is required', str(response.data['shipment']))
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Test with a single line items
 | 
					        # Test with a single line items
 | 
				
			||||||
        line = self.order.lines.first()
 | 
					        line = self.order.lines.first()
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -50,7 +50,7 @@ from InvenTree.mixins import (
 | 
				
			|||||||
    RetrieveAPI,
 | 
					    RetrieveAPI,
 | 
				
			||||||
    RetrieveUpdateDestroyAPI,
 | 
					    RetrieveUpdateDestroyAPI,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
from order.models import PurchaseOrder, ReturnOrder, SalesOrder, SalesOrderAllocation
 | 
					from order.models import PurchaseOrder, ReturnOrder, SalesOrder
 | 
				
			||||||
from order.serializers import (
 | 
					from order.serializers import (
 | 
				
			||||||
    PurchaseOrderSerializer,
 | 
					    PurchaseOrderSerializer,
 | 
				
			||||||
    ReturnOrderSerializer,
 | 
					    ReturnOrderSerializer,
 | 
				
			||||||
@@ -101,55 +101,6 @@ class GenerateSerialNumber(GenericAPIView):
 | 
				
			|||||||
        return Response(data, status=status.HTTP_201_CREATED)
 | 
					        return Response(data, status=status.HTTP_201_CREATED)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class StockDetail(RetrieveUpdateDestroyAPI):
 | 
					 | 
				
			||||||
    """API detail endpoint for Stock object.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    get:
 | 
					 | 
				
			||||||
    Return a single StockItem object
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    post:
 | 
					 | 
				
			||||||
    Update a StockItem
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    delete:
 | 
					 | 
				
			||||||
    Remove a StockItem
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    queryset = StockItem.objects.all()
 | 
					 | 
				
			||||||
    serializer_class = StockSerializers.StockItemSerializer
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def get_queryset(self, *args, **kwargs):
 | 
					 | 
				
			||||||
        """Annotate queryset."""
 | 
					 | 
				
			||||||
        queryset = super().get_queryset(*args, **kwargs)
 | 
					 | 
				
			||||||
        queryset = StockSerializers.StockItemSerializer.annotate_queryset(queryset)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return queryset
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def get_serializer_context(self):
 | 
					 | 
				
			||||||
        """Extend serializer context."""
 | 
					 | 
				
			||||||
        ctx = super().get_serializer_context()
 | 
					 | 
				
			||||||
        ctx['user'] = getattr(self.request, 'user', None)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return ctx
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def get_serializer(self, *args, **kwargs):
 | 
					 | 
				
			||||||
        """Set context before returning serializer."""
 | 
					 | 
				
			||||||
        kwargs['context'] = self.get_serializer_context()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            params = self.request.query_params
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            kwargs['part_detail'] = str2bool(params.get('part_detail', True))
 | 
					 | 
				
			||||||
            kwargs['location_detail'] = str2bool(params.get('location_detail', True))
 | 
					 | 
				
			||||||
            kwargs['supplier_part_detail'] = str2bool(
 | 
					 | 
				
			||||||
                params.get('supplier_part_detail', True)
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
            kwargs['path_detail'] = str2bool(params.get('path_detail', False))
 | 
					 | 
				
			||||||
        except AttributeError:  # pragma: no cover
 | 
					 | 
				
			||||||
            pass
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return self.serializer_class(*args, **kwargs)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class StockItemContextMixin:
 | 
					class StockItemContextMixin:
 | 
				
			||||||
    """Mixin class for adding StockItem object to serializer context."""
 | 
					    """Mixin class for adding StockItem object to serializer context."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -531,54 +482,88 @@ class StockFilter(rest_filters.FilterSet):
 | 
				
			|||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    supplier = rest_filters.ModelChoiceFilter(
 | 
					    supplier = rest_filters.ModelChoiceFilter(
 | 
				
			||||||
        label='Supplier',
 | 
					        label=_('Supplier'),
 | 
				
			||||||
        queryset=Company.objects.filter(is_supplier=True),
 | 
					        queryset=Company.objects.filter(is_supplier=True),
 | 
				
			||||||
        field_name='supplier_part__supplier',
 | 
					        field_name='supplier_part__supplier',
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    include_variants = rest_filters.BooleanFilter(
 | 
				
			||||||
 | 
					        label=_('Include Variants'), method='filter_include_variants'
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def filter_include_variants(self, queryset, name, value):
 | 
				
			||||||
 | 
					        """Filter by whether or not to include variants of the selected part.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Note:
 | 
				
			||||||
 | 
					        - This filter does nothing by itself, and requires the 'part' filter to be set.
 | 
				
			||||||
 | 
					        - Refer to the 'filter_part' method for more information.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return queryset
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    part = rest_filters.ModelChoiceFilter(
 | 
				
			||||||
 | 
					        label=_('Part'), queryset=Part.objects.all(), method='filter_part'
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def filter_part(self, queryset, name, part):
 | 
				
			||||||
 | 
					        """Filter StockItem list by provided Part instance.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Note:
 | 
				
			||||||
 | 
					        - If "part" is a variant, include all variants of the selected part
 | 
				
			||||||
 | 
					        - Otherwise, filter by the selected part
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        include_variants = str2bool(self.data.get('include_variants', True))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if include_variants:
 | 
				
			||||||
 | 
					            return queryset.filter(part__in=part.get_descendants(include_self=True))
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            return queryset.filter(part=part)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # Part name filters
 | 
					    # Part name filters
 | 
				
			||||||
    name = rest_filters.CharFilter(
 | 
					    name = rest_filters.CharFilter(
 | 
				
			||||||
        label='Part name (case insensitive)',
 | 
					        label=_('Part name (case insensitive)'),
 | 
				
			||||||
        field_name='part__name',
 | 
					        field_name='part__name',
 | 
				
			||||||
        lookup_expr='iexact',
 | 
					        lookup_expr='iexact',
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    name_contains = rest_filters.CharFilter(
 | 
					    name_contains = rest_filters.CharFilter(
 | 
				
			||||||
        label='Part name contains (case insensitive)',
 | 
					        label=_('Part name contains (case insensitive)'),
 | 
				
			||||||
        field_name='part__name',
 | 
					        field_name='part__name',
 | 
				
			||||||
        lookup_expr='icontains',
 | 
					        lookup_expr='icontains',
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    name_regex = rest_filters.CharFilter(
 | 
					    name_regex = rest_filters.CharFilter(
 | 
				
			||||||
        label='Part name (regex)', field_name='part__name', lookup_expr='iregex'
 | 
					        label=_('Part name (regex)'), field_name='part__name', lookup_expr='iregex'
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # Part IPN filters
 | 
					    # Part IPN filters
 | 
				
			||||||
    IPN = rest_filters.CharFilter(
 | 
					    IPN = rest_filters.CharFilter(
 | 
				
			||||||
        label='Part IPN (case insensitive)',
 | 
					        label=_('Part IPN (case insensitive)'),
 | 
				
			||||||
        field_name='part__IPN',
 | 
					        field_name='part__IPN',
 | 
				
			||||||
        lookup_expr='iexact',
 | 
					        lookup_expr='iexact',
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    IPN_contains = rest_filters.CharFilter(
 | 
					    IPN_contains = rest_filters.CharFilter(
 | 
				
			||||||
        label='Part IPN contains (case insensitive)',
 | 
					        label=_('Part IPN contains (case insensitive)'),
 | 
				
			||||||
        field_name='part__IPN',
 | 
					        field_name='part__IPN',
 | 
				
			||||||
        lookup_expr='icontains',
 | 
					        lookup_expr='icontains',
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    IPN_regex = rest_filters.CharFilter(
 | 
					    IPN_regex = rest_filters.CharFilter(
 | 
				
			||||||
        label='Part IPN (regex)', field_name='part__IPN', lookup_expr='iregex'
 | 
					        label=_('Part IPN (regex)'), field_name='part__IPN', lookup_expr='iregex'
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # Part attribute filters
 | 
					    # Part attribute filters
 | 
				
			||||||
    assembly = rest_filters.BooleanFilter(label='Assembly', field_name='part__assembly')
 | 
					    assembly = rest_filters.BooleanFilter(
 | 
				
			||||||
    active = rest_filters.BooleanFilter(label='Active', field_name='part__active')
 | 
					        label=_('Assembly'), field_name='part__assembly'
 | 
				
			||||||
    salable = rest_filters.BooleanFilter(label='Salable', field_name='part__salable')
 | 
					    )
 | 
				
			||||||
 | 
					    active = rest_filters.BooleanFilter(label=_('Active'), field_name='part__active')
 | 
				
			||||||
 | 
					    salable = rest_filters.BooleanFilter(label=_('Salable'), field_name='part__salable')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    min_stock = rest_filters.NumberFilter(
 | 
					    min_stock = rest_filters.NumberFilter(
 | 
				
			||||||
        label='Minimum stock', field_name='quantity', lookup_expr='gte'
 | 
					        label=_('Minimum stock'), field_name='quantity', lookup_expr='gte'
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    max_stock = rest_filters.NumberFilter(
 | 
					    max_stock = rest_filters.NumberFilter(
 | 
				
			||||||
        label='Maximum stock', field_name='quantity', lookup_expr='lte'
 | 
					        label=_('Maximum stock'), field_name='quantity', lookup_expr='lte'
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    status = rest_filters.NumberFilter(label='Status Code', method='filter_status')
 | 
					    status = rest_filters.NumberFilter(label=_('Status Code'), method='filter_status')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def filter_status(self, queryset, name, value):
 | 
					    def filter_status(self, queryset, name, value):
 | 
				
			||||||
        """Filter by integer status code."""
 | 
					        """Filter by integer status code."""
 | 
				
			||||||
@@ -860,17 +845,25 @@ class StockFilter(rest_filters.FilterSet):
 | 
				
			|||||||
            return queryset.exclude(stale_filter)
 | 
					            return queryset.exclude(stale_filter)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class StockList(DataExportViewMixin, ListCreateDestroyAPIView):
 | 
					class StockApiMixin:
 | 
				
			||||||
    """API endpoint for list view of Stock objects.
 | 
					    """Mixin class for StockItem API endpoints."""
 | 
				
			||||||
 | 
					 | 
				
			||||||
    - GET: Return a list of all StockItem objects (with optional query filters)
 | 
					 | 
				
			||||||
    - POST: Create a new StockItem
 | 
					 | 
				
			||||||
    - DELETE: Delete multiple StockItem objects
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    serializer_class = StockSerializers.StockItemSerializer
 | 
					    serializer_class = StockSerializers.StockItemSerializer
 | 
				
			||||||
    queryset = StockItem.objects.all()
 | 
					    queryset = StockItem.objects.all()
 | 
				
			||||||
    filterset_class = StockFilter
 | 
					
 | 
				
			||||||
 | 
					    def get_queryset(self, *args, **kwargs):
 | 
				
			||||||
 | 
					        """Annotate queryset."""
 | 
				
			||||||
 | 
					        queryset = super().get_queryset(*args, **kwargs)
 | 
				
			||||||
 | 
					        queryset = StockSerializers.StockItemSerializer.annotate_queryset(queryset)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return queryset
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_serializer_context(self):
 | 
				
			||||||
 | 
					        """Extend serializer context."""
 | 
				
			||||||
 | 
					        ctx = super().get_serializer_context()
 | 
				
			||||||
 | 
					        ctx['user'] = getattr(self.request, 'user', None)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return ctx
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_serializer(self, *args, **kwargs):
 | 
					    def get_serializer(self, *args, **kwargs):
 | 
				
			||||||
        """Set context before returning serializer.
 | 
					        """Set context before returning serializer.
 | 
				
			||||||
@@ -899,12 +892,16 @@ class StockList(DataExportViewMixin, ListCreateDestroyAPIView):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        return self.serializer_class(*args, **kwargs)
 | 
					        return self.serializer_class(*args, **kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_serializer_context(self):
 | 
					 | 
				
			||||||
        """Extend serializer context."""
 | 
					 | 
				
			||||||
        ctx = super().get_serializer_context()
 | 
					 | 
				
			||||||
        ctx['user'] = getattr(self.request, 'user', None)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return ctx
 | 
					class StockList(DataExportViewMixin, StockApiMixin, ListCreateDestroyAPIView):
 | 
				
			||||||
 | 
					    """API endpoint for list view of Stock objects.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    - GET: Return a list of all StockItem objects (with optional query filters)
 | 
				
			||||||
 | 
					    - POST: Create a new StockItem
 | 
				
			||||||
 | 
					    - DELETE: Delete multiple StockItem objects
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    filterset_class = StockFilter
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def create(self, request, *args, **kwargs):
 | 
					    def create(self, request, *args, **kwargs):
 | 
				
			||||||
        """Create a new StockItem object via the API.
 | 
					        """Create a new StockItem object via the API.
 | 
				
			||||||
@@ -1079,14 +1076,6 @@ class StockList(DataExportViewMixin, ListCreateDestroyAPIView):
 | 
				
			|||||||
            headers=self.get_success_headers(serializer.data),
 | 
					            headers=self.get_success_headers(serializer.data),
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_queryset(self, *args, **kwargs):
 | 
					 | 
				
			||||||
        """Annotate queryset before returning."""
 | 
					 | 
				
			||||||
        queryset = super().get_queryset(*args, **kwargs)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        queryset = StockSerializers.StockItemSerializer.annotate_queryset(queryset)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return queryset
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def filter_queryset(self, queryset):
 | 
					    def filter_queryset(self, queryset):
 | 
				
			||||||
        """Custom filtering for the StockItem queryset."""
 | 
					        """Custom filtering for the StockItem queryset."""
 | 
				
			||||||
        params = self.request.query_params
 | 
					        params = self.request.query_params
 | 
				
			||||||
@@ -1107,46 +1096,6 @@ class StockList(DataExportViewMixin, ListCreateDestroyAPIView):
 | 
				
			|||||||
            except (ValueError, StockItem.DoesNotExist):  # pragma: no cover
 | 
					            except (ValueError, StockItem.DoesNotExist):  # pragma: no cover
 | 
				
			||||||
                pass
 | 
					                pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Exclude StockItems which are already allocated to a particular SalesOrder
 | 
					 | 
				
			||||||
        exclude_so_allocation = params.get('exclude_so_allocation', None)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if exclude_so_allocation is not None:
 | 
					 | 
				
			||||||
            try:
 | 
					 | 
				
			||||||
                order = SalesOrder.objects.get(pk=exclude_so_allocation)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                # Grab all the active SalesOrderAllocations for this order
 | 
					 | 
				
			||||||
                allocations = SalesOrderAllocation.objects.filter(
 | 
					 | 
				
			||||||
                    line__pk__in=[line.pk for line in order.lines.all()]
 | 
					 | 
				
			||||||
                )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                # Exclude any stock item which is already allocated to the sales order
 | 
					 | 
				
			||||||
                queryset = queryset.exclude(pk__in=[a.item.pk for a in allocations])
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            except (ValueError, SalesOrder.DoesNotExist):  # pragma: no cover
 | 
					 | 
				
			||||||
                pass
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # Does the client wish to filter by the Part ID?
 | 
					 | 
				
			||||||
        part_id = params.get('part', None)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if part_id:
 | 
					 | 
				
			||||||
            try:
 | 
					 | 
				
			||||||
                part = Part.objects.get(pk=part_id)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                # Do we wish to filter *just* for this part, or also for parts *under* this one?
 | 
					 | 
				
			||||||
                include_variants = str2bool(params.get('include_variants', True))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                if include_variants:
 | 
					 | 
				
			||||||
                    # Filter by any parts "under" the given part
 | 
					 | 
				
			||||||
                    parts = part.get_descendants(include_self=True)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                    queryset = queryset.filter(part__in=parts)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                else:
 | 
					 | 
				
			||||||
                    queryset = queryset.filter(part=part)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            except (ValueError, Part.DoesNotExist):
 | 
					 | 
				
			||||||
                raise ValidationError({'part': 'Invalid Part ID specified'})
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # Does the client wish to filter by stock location?
 | 
					        # Does the client wish to filter by stock location?
 | 
				
			||||||
        loc_id = params.get('location', None)
 | 
					        loc_id = params.get('location', None)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -1212,6 +1161,10 @@ class StockList(DataExportViewMixin, ListCreateDestroyAPIView):
 | 
				
			|||||||
    ]
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class StockDetail(StockApiMixin, RetrieveUpdateDestroyAPI):
 | 
				
			||||||
 | 
					    """API detail endpoint for a single StockItem instance."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class StockItemTestResultMixin:
 | 
					class StockItemTestResultMixin:
 | 
				
			||||||
    """Mixin class for the StockItemTestResult API endpoints."""
 | 
					    """Mixin class for the StockItemTestResult API endpoints."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -11,6 +11,8 @@
 | 
				
			|||||||
    constructField,
 | 
					    constructField,
 | 
				
			||||||
    constructForm,
 | 
					    constructForm,
 | 
				
			||||||
    constructOrderTableButtons,
 | 
					    constructOrderTableButtons,
 | 
				
			||||||
 | 
					    disableFormInput,
 | 
				
			||||||
 | 
					    enableFormInput,
 | 
				
			||||||
    endDate,
 | 
					    endDate,
 | 
				
			||||||
    formatCurrency,
 | 
					    formatCurrency,
 | 
				
			||||||
    FullCalendar,
 | 
					    FullCalendar,
 | 
				
			||||||
@@ -1559,17 +1561,35 @@ function showAllocationSubTable(index, row, element, options) {
 | 
				
			|||||||
        // Add callbacks for 'edit' buttons
 | 
					        // Add callbacks for 'edit' buttons
 | 
				
			||||||
        table.find('.button-allocation-edit').click(function() {
 | 
					        table.find('.button-allocation-edit').click(function() {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            var pk = $(this).attr('pk');
 | 
					            let pk = $(this).attr('pk');
 | 
				
			||||||
 | 
					            let allocation = table.bootstrapTable('getRowByUniqueId', pk);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            let disableShipment = allocation && allocation.shipment_detail?.shipment_date;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            // Edit the sales order allocation
 | 
					            // Edit the sales order allocation
 | 
				
			||||||
            constructForm(
 | 
					            constructForm(
 | 
				
			||||||
                `/api/order/so-allocation/${pk}/`,
 | 
					                `/api/order/so-allocation/${pk}/`,
 | 
				
			||||||
                {
 | 
					                {
 | 
				
			||||||
                    fields: {
 | 
					                    fields: {
 | 
				
			||||||
 | 
					                        item: {},
 | 
				
			||||||
                        quantity: {},
 | 
					                        quantity: {},
 | 
				
			||||||
 | 
					                        shipment: {
 | 
				
			||||||
 | 
					                            filters: {
 | 
				
			||||||
 | 
					                                order: allocation.order,
 | 
				
			||||||
 | 
					                                shipped: false,
 | 
				
			||||||
 | 
					                            }
 | 
				
			||||||
 | 
					                        }
 | 
				
			||||||
                    },
 | 
					                    },
 | 
				
			||||||
                    title: '{% trans "Edit Stock Allocation" %}',
 | 
					                    title: '{% trans "Edit Stock Allocation" %}',
 | 
				
			||||||
                    refreshTable: options.table,
 | 
					                    refreshTable: options.table,
 | 
				
			||||||
 | 
					                    afterRender: function(fields, opts) {
 | 
				
			||||||
 | 
					                        disableFormInput('item', opts);
 | 
				
			||||||
 | 
					                        if (disableShipment) {
 | 
				
			||||||
 | 
					                            disableFormInput('shipment', opts);
 | 
				
			||||||
 | 
					                        } else {
 | 
				
			||||||
 | 
					                            enableFormInput('shipment', opts);
 | 
				
			||||||
 | 
					                        }
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
                },
 | 
					                },
 | 
				
			||||||
            );
 | 
					            );
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
@@ -1593,6 +1613,8 @@ function showAllocationSubTable(index, row, element, options) {
 | 
				
			|||||||
    table.bootstrapTable({
 | 
					    table.bootstrapTable({
 | 
				
			||||||
        url: '{% url "api-so-allocation-list" %}',
 | 
					        url: '{% url "api-so-allocation-list" %}',
 | 
				
			||||||
        onPostBody: setupCallbacks,
 | 
					        onPostBody: setupCallbacks,
 | 
				
			||||||
 | 
					        uniqueId: 'pk',
 | 
				
			||||||
 | 
					        idField: 'pk',
 | 
				
			||||||
        queryParams: {
 | 
					        queryParams: {
 | 
				
			||||||
            ...options.queryParams,
 | 
					            ...options.queryParams,
 | 
				
			||||||
            part_detail: true,
 | 
					            part_detail: true,
 | 
				
			||||||
@@ -1614,7 +1636,11 @@ function showAllocationSubTable(index, row, element, options) {
 | 
				
			|||||||
                field: 'shipment',
 | 
					                field: 'shipment',
 | 
				
			||||||
                title: '{% trans "Shipment" %}',
 | 
					                title: '{% trans "Shipment" %}',
 | 
				
			||||||
                formatter: function(value, row) {
 | 
					                formatter: function(value, row) {
 | 
				
			||||||
 | 
					                    if (row.shipment_detail) {
 | 
				
			||||||
                        return row.shipment_detail.reference;
 | 
					                        return row.shipment_detail.reference;
 | 
				
			||||||
 | 
					                    } else {
 | 
				
			||||||
 | 
					                        return '{% trans "No shipment" %}';
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -356,13 +356,29 @@ export function useSalesOrderShipmentCompleteFields({
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function useSalesOrderAllocationFields({
 | 
					export function useSalesOrderAllocationFields({
 | 
				
			||||||
  shipmentId
 | 
					  orderId,
 | 
				
			||||||
 | 
					  shipment
 | 
				
			||||||
}: {
 | 
					}: {
 | 
				
			||||||
  shipmentId?: number;
 | 
					  orderId?: number;
 | 
				
			||||||
 | 
					  shipment: any | null;
 | 
				
			||||||
}): ApiFormFieldSet {
 | 
					}): ApiFormFieldSet {
 | 
				
			||||||
  return useMemo(() => {
 | 
					  return useMemo(() => {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      quantity: {}
 | 
					      item: {
 | 
				
			||||||
    };
 | 
					        // Cannot change item, but display for reference
 | 
				
			||||||
  }, [shipmentId]);
 | 
					        disabled: true
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      quantity: {},
 | 
				
			||||||
 | 
					      shipment: {
 | 
				
			||||||
 | 
					        // Cannot change shipment once it has been shipped
 | 
				
			||||||
 | 
					        disabled: !!shipment?.shipment_date,
 | 
				
			||||||
 | 
					        // Order ID is required for this field to be accessed
 | 
				
			||||||
 | 
					        hidden: !orderId,
 | 
				
			||||||
 | 
					        filters: {
 | 
				
			||||||
 | 
					          order: orderId,
 | 
				
			||||||
 | 
					          shipped: false
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  }, [orderId, shipment]);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -131,23 +131,23 @@ export default function SalesOrderDetail() {
 | 
				
			|||||||
        icon: 'progress',
 | 
					        icon: 'progress',
 | 
				
			||||||
        label: t`Completed Line Items`,
 | 
					        label: t`Completed Line Items`,
 | 
				
			||||||
        total: order.line_items,
 | 
					        total: order.line_items,
 | 
				
			||||||
        progress: order.completed_lines
 | 
					        progress: order.completed_lines,
 | 
				
			||||||
 | 
					        hidden: !order.line_items
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      {
 | 
					      {
 | 
				
			||||||
        type: 'progressbar',
 | 
					        type: 'progressbar',
 | 
				
			||||||
        name: 'shipments',
 | 
					        name: 'shipments',
 | 
				
			||||||
        icon: 'shipment',
 | 
					        icon: 'shipment',
 | 
				
			||||||
        label: t`Completed Shipments`,
 | 
					        label: t`Completed Shipments`,
 | 
				
			||||||
        total: order.shipments,
 | 
					        total: order.shipments_count,
 | 
				
			||||||
        progress: order.completed_shipments,
 | 
					        progress: order.completed_shipments_count,
 | 
				
			||||||
        hidden: !order.shipments
 | 
					        hidden: !order.shipments_count
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      {
 | 
					      {
 | 
				
			||||||
        type: 'text',
 | 
					        type: 'text',
 | 
				
			||||||
        name: 'currency',
 | 
					        name: 'currency',
 | 
				
			||||||
        label: t`Order Currency`,
 | 
					        label: t`Order Currency`,
 | 
				
			||||||
        value_formatter: () =>
 | 
					        value_formatter: () => orderCurrency
 | 
				
			||||||
          order?.order_currency ?? order?.customer_detail.currency
 | 
					 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      {
 | 
					      {
 | 
				
			||||||
        type: 'text',
 | 
					        type: 'text',
 | 
				
			||||||
@@ -155,7 +155,7 @@ export default function SalesOrderDetail() {
 | 
				
			|||||||
        label: t`Total Cost`,
 | 
					        label: t`Total Cost`,
 | 
				
			||||||
        value_formatter: () => {
 | 
					        value_formatter: () => {
 | 
				
			||||||
          return formatCurrency(order?.total_price, {
 | 
					          return formatCurrency(order?.total_price, {
 | 
				
			||||||
            currency: order?.order_currency ?? order?.customer_detail?.currency
 | 
					            currency: orderCurrency
 | 
				
			||||||
          });
 | 
					          });
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
@@ -249,7 +249,7 @@ export default function SalesOrderDetail() {
 | 
				
			|||||||
        <DetailsTable fields={br} item={order} />
 | 
					        <DetailsTable fields={br} item={order} />
 | 
				
			||||||
      </ItemDetailsGrid>
 | 
					      </ItemDetailsGrid>
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }, [order, instanceQuery]);
 | 
					  }, [order, orderCurrency, instanceQuery]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const soStatus = useStatusCodes({ modelType: ModelType.salesorder });
 | 
					  const soStatus = useStatusCodes({ modelType: ModelType.salesorder });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -354,6 +354,7 @@ export default function SalesOrderDetail() {
 | 
				
			|||||||
        name: 'build-orders',
 | 
					        name: 'build-orders',
 | 
				
			||||||
        label: t`Build Orders`,
 | 
					        label: t`Build Orders`,
 | 
				
			||||||
        icon: <IconTools />,
 | 
					        icon: <IconTools />,
 | 
				
			||||||
 | 
					        hidden: !user.hasViewRole(UserRoles.build),
 | 
				
			||||||
        content: order?.pk ? (
 | 
					        content: order?.pk ? (
 | 
				
			||||||
          <BuildOrderTable salesOrderId={order.pk} />
 | 
					          <BuildOrderTable salesOrderId={order.pk} />
 | 
				
			||||||
        ) : (
 | 
					        ) : (
 | 
				
			||||||
@@ -369,7 +370,7 @@ export default function SalesOrderDetail() {
 | 
				
			|||||||
        model_id: order.pk
 | 
					        model_id: order.pk
 | 
				
			||||||
      })
 | 
					      })
 | 
				
			||||||
    ];
 | 
					    ];
 | 
				
			||||||
  }, [order, id, user, soStatus]);
 | 
					  }, [order, id, user, soStatus, user]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const issueOrder = useCreateApiFormModal({
 | 
					  const issueOrder = useCreateApiFormModal({
 | 
				
			||||||
    url: apiUrl(ApiEndpoints.sales_order_issue, order.pk),
 | 
					    url: apiUrl(ApiEndpoints.sales_order_issue, order.pk),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -55,6 +55,7 @@ const PAGE_SIZES = [10, 15, 20, 25, 50, 100, 500];
 | 
				
			|||||||
 * @param onRowClick : (record: any, index: number, event: any) => void - Callback function when a row is clicked
 | 
					 * @param onRowClick : (record: any, index: number, event: any) => void - Callback function when a row is clicked
 | 
				
			||||||
 * @param onCellClick : (event: any, record: any, index: number, column: any, columnIndex: number) => void - Callback function when a cell is clicked
 | 
					 * @param onCellClick : (event: any, record: any, index: number, column: any, columnIndex: number) => void - Callback function when a cell is clicked
 | 
				
			||||||
 * @param modelType: ModelType - The model type for the table
 | 
					 * @param modelType: ModelType - The model type for the table
 | 
				
			||||||
 | 
					 * @param minHeight: number - Minimum height of the table (default 300px)
 | 
				
			||||||
 * @param noHeader: boolean - Hide the table header
 | 
					 * @param noHeader: boolean - Hide the table header
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
export type InvenTreeTableProps<T = any> = {
 | 
					export type InvenTreeTableProps<T = any> = {
 | 
				
			||||||
@@ -85,6 +86,7 @@ export type InvenTreeTableProps<T = any> = {
 | 
				
			|||||||
  modelType?: ModelType;
 | 
					  modelType?: ModelType;
 | 
				
			||||||
  rowStyle?: (record: T, index: number) => any;
 | 
					  rowStyle?: (record: T, index: number) => any;
 | 
				
			||||||
  modelField?: string;
 | 
					  modelField?: string;
 | 
				
			||||||
 | 
					  minHeight?: number;
 | 
				
			||||||
  noHeader?: boolean;
 | 
					  noHeader?: boolean;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -631,7 +633,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
 | 
				
			|||||||
              loaderType={loader}
 | 
					              loaderType={loader}
 | 
				
			||||||
              pinLastColumn={tableProps.rowActions != undefined}
 | 
					              pinLastColumn={tableProps.rowActions != undefined}
 | 
				
			||||||
              idAccessor={tableProps.idAccessor}
 | 
					              idAccessor={tableProps.idAccessor}
 | 
				
			||||||
              minHeight={300}
 | 
					              minHeight={tableProps.minHeight ?? 300}
 | 
				
			||||||
              totalRecords={tableState.recordCount}
 | 
					              totalRecords={tableState.recordCount}
 | 
				
			||||||
              recordsPerPage={tableState.pageSize}
 | 
					              recordsPerPage={tableState.pageSize}
 | 
				
			||||||
              page={tableState.page}
 | 
					              page={tableState.page}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,7 @@
 | 
				
			|||||||
import { t } from '@lingui/macro';
 | 
					import { t } from '@lingui/macro';
 | 
				
			||||||
import { ActionIcon, Menu, Tooltip } from '@mantine/core';
 | 
					import { ActionIcon, Menu, Tooltip } from '@mantine/core';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
 | 
					  IconArrowRight,
 | 
				
			||||||
  IconCircleX,
 | 
					  IconCircleX,
 | 
				
			||||||
  IconCopy,
 | 
					  IconCopy,
 | 
				
			||||||
  IconDots,
 | 
					  IconDots,
 | 
				
			||||||
@@ -8,8 +9,12 @@ import {
 | 
				
			|||||||
  IconTrash
 | 
					  IconTrash
 | 
				
			||||||
} from '@tabler/icons-react';
 | 
					} from '@tabler/icons-react';
 | 
				
			||||||
import { ReactNode, useMemo, useState } from 'react';
 | 
					import { ReactNode, useMemo, useState } from 'react';
 | 
				
			||||||
 | 
					import { NavigateFunction } from 'react-router-dom';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { ModelType } from '../enums/ModelType';
 | 
				
			||||||
import { cancelEvent } from '../functions/events';
 | 
					import { cancelEvent } from '../functions/events';
 | 
				
			||||||
 | 
					import { navigateToLink } from '../functions/navigation';
 | 
				
			||||||
 | 
					import { getDetailUrl } from '../functions/urls';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Type definition for a table row action
 | 
					// Type definition for a table row action
 | 
				
			||||||
export type RowAction = {
 | 
					export type RowAction = {
 | 
				
			||||||
@@ -17,11 +22,32 @@ export type RowAction = {
 | 
				
			|||||||
  tooltip?: string;
 | 
					  tooltip?: string;
 | 
				
			||||||
  color?: string;
 | 
					  color?: string;
 | 
				
			||||||
  icon?: ReactNode;
 | 
					  icon?: ReactNode;
 | 
				
			||||||
  onClick: (event: any) => void;
 | 
					  onClick?: (event: any) => void;
 | 
				
			||||||
  hidden?: boolean;
 | 
					  hidden?: boolean;
 | 
				
			||||||
  disabled?: boolean;
 | 
					  disabled?: boolean;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type RowModelProps = {
 | 
				
			||||||
 | 
					  modelType: ModelType;
 | 
				
			||||||
 | 
					  modelId: number;
 | 
				
			||||||
 | 
					  navigate: NavigateFunction;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type RowViewProps = RowAction & RowModelProps;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Component for viewing a row in a table
 | 
				
			||||||
 | 
					export function RowViewAction(props: RowViewProps): RowAction {
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    ...props,
 | 
				
			||||||
 | 
					    color: undefined,
 | 
				
			||||||
 | 
					    icon: <IconArrowRight />,
 | 
				
			||||||
 | 
					    onClick: (event: any) => {
 | 
				
			||||||
 | 
					      const url = getDetailUrl(props.modelType, props.modelId);
 | 
				
			||||||
 | 
					      navigateToLink(url, props.navigate, event);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Component for duplicating a row in a table
 | 
					// Component for duplicating a row in a table
 | 
				
			||||||
export function RowDuplicateAction(props: RowAction): RowAction {
 | 
					export function RowDuplicateAction(props: RowAction): RowAction {
 | 
				
			||||||
  return {
 | 
					  return {
 | 
				
			||||||
@@ -105,7 +131,7 @@ export function RowActions({
 | 
				
			|||||||
          onClick={(event) => {
 | 
					          onClick={(event) => {
 | 
				
			||||||
            // Prevent clicking on the action from selecting the row itself
 | 
					            // Prevent clicking on the action from selecting the row itself
 | 
				
			||||||
            cancelEvent(event);
 | 
					            cancelEvent(event);
 | 
				
			||||||
            action.onClick(event);
 | 
					            action.onClick?.(event);
 | 
				
			||||||
            setOpened(false);
 | 
					            setOpened(false);
 | 
				
			||||||
          }}
 | 
					          }}
 | 
				
			||||||
          disabled={action.disabled || false}
 | 
					          disabled={action.disabled || false}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -23,6 +23,7 @@ import { ModelType } from '../../enums/ModelType';
 | 
				
			|||||||
import { UserRoles } from '../../enums/Roles';
 | 
					import { UserRoles } from '../../enums/Roles';
 | 
				
			||||||
import { bomItemFields } from '../../forms/BomForms';
 | 
					import { bomItemFields } from '../../forms/BomForms';
 | 
				
			||||||
import { dataImporterSessionFields } from '../../forms/ImporterForms';
 | 
					import { dataImporterSessionFields } from '../../forms/ImporterForms';
 | 
				
			||||||
 | 
					import { navigateToLink } from '../../functions/navigation';
 | 
				
			||||||
import { notYetImplemented } from '../../functions/notifications';
 | 
					import { notYetImplemented } from '../../functions/notifications';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  useApiFormModal,
 | 
					  useApiFormModal,
 | 
				
			||||||
@@ -461,7 +462,9 @@ export function BomTable({
 | 
				
			|||||||
        return [
 | 
					        return [
 | 
				
			||||||
          {
 | 
					          {
 | 
				
			||||||
            title: t`View BOM`,
 | 
					            title: t`View BOM`,
 | 
				
			||||||
            onClick: () => navigate(`/part/${record.part}/`),
 | 
					            onClick: (event: any) => {
 | 
				
			||||||
 | 
					              navigateToLink(`/part/${record.part}/bom/`, navigate, event);
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
            icon: <IconArrowRight />
 | 
					            icon: <IconArrowRight />
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
        ];
 | 
					        ];
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -22,9 +22,7 @@ import {
 | 
				
			|||||||
  useAllocateStockToBuildForm,
 | 
					  useAllocateStockToBuildForm,
 | 
				
			||||||
  useBuildOrderFields
 | 
					  useBuildOrderFields
 | 
				
			||||||
} from '../../forms/BuildForms';
 | 
					} from '../../forms/BuildForms';
 | 
				
			||||||
import { navigateToLink } from '../../functions/navigation';
 | 
					 | 
				
			||||||
import { notYetImplemented } from '../../functions/notifications';
 | 
					import { notYetImplemented } from '../../functions/notifications';
 | 
				
			||||||
import { getDetailUrl } from '../../functions/urls';
 | 
					 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  useCreateApiFormModal,
 | 
					  useCreateApiFormModal,
 | 
				
			||||||
  useDeleteApiFormModal,
 | 
					  useDeleteApiFormModal,
 | 
				
			||||||
@@ -42,7 +40,8 @@ import {
 | 
				
			|||||||
  RowAction,
 | 
					  RowAction,
 | 
				
			||||||
  RowActions,
 | 
					  RowActions,
 | 
				
			||||||
  RowDeleteAction,
 | 
					  RowDeleteAction,
 | 
				
			||||||
  RowEditAction
 | 
					  RowEditAction,
 | 
				
			||||||
 | 
					  RowViewAction
 | 
				
			||||||
} from '../RowActions';
 | 
					} from '../RowActions';
 | 
				
			||||||
import { TableHoverCard } from '../TableHoverCard';
 | 
					import { TableHoverCard } from '../TableHoverCard';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -605,20 +604,15 @@ export default function BuildLineTable({
 | 
				
			|||||||
            newBuildOrder.open();
 | 
					            newBuildOrder.open();
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        {
 | 
					        RowViewAction({
 | 
				
			||||||
          icon: <IconArrowRight />,
 | 
					 | 
				
			||||||
          title: t`View Part`,
 | 
					          title: t`View Part`,
 | 
				
			||||||
          onClick: (event: any) => {
 | 
					          modelType: ModelType.part,
 | 
				
			||||||
            navigateToLink(
 | 
					          modelId: record.part,
 | 
				
			||||||
              getDetailUrl(ModelType.part, record.part),
 | 
					          navigate: navigate
 | 
				
			||||||
              navigate,
 | 
					        })
 | 
				
			||||||
              event
 | 
					 | 
				
			||||||
            );
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      ];
 | 
					      ];
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    [user, output, build, buildStatus]
 | 
					    [user, navigate, output, build, buildStatus]
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const tableActions = useMemo(() => {
 | 
					  const tableActions = useMemo(() => {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -16,6 +16,7 @@ import {
 | 
				
			|||||||
} from '@tabler/icons-react';
 | 
					} from '@tabler/icons-react';
 | 
				
			||||||
import { useQuery } from '@tanstack/react-query';
 | 
					import { useQuery } from '@tanstack/react-query';
 | 
				
			||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
 | 
					import { useCallback, useEffect, useMemo, useState } from 'react';
 | 
				
			||||||
 | 
					import { useNavigate } from 'react-router-dom';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { api } from '../../App';
 | 
					import { api } from '../../App';
 | 
				
			||||||
import { ActionButton } from '../../components/buttons/ActionButton';
 | 
					import { ActionButton } from '../../components/buttons/ActionButton';
 | 
				
			||||||
@@ -43,7 +44,7 @@ import { useUserState } from '../../states/UserState';
 | 
				
			|||||||
import { TableColumn } from '../Column';
 | 
					import { TableColumn } from '../Column';
 | 
				
			||||||
import { LocationColumn, PartColumn, StatusColumn } from '../ColumnRenderers';
 | 
					import { LocationColumn, PartColumn, StatusColumn } from '../ColumnRenderers';
 | 
				
			||||||
import { InvenTreeTable } from '../InvenTreeTable';
 | 
					import { InvenTreeTable } from '../InvenTreeTable';
 | 
				
			||||||
import { RowAction, RowEditAction } from '../RowActions';
 | 
					import { RowAction, RowEditAction, RowViewAction } from '../RowActions';
 | 
				
			||||||
import { TableHoverCard } from '../TableHoverCard';
 | 
					import { TableHoverCard } from '../TableHoverCard';
 | 
				
			||||||
import BuildLineTable from './BuildLineTable';
 | 
					import BuildLineTable from './BuildLineTable';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -123,6 +124,7 @@ export default function BuildOutputTable({
 | 
				
			|||||||
  refreshBuild
 | 
					  refreshBuild
 | 
				
			||||||
}: Readonly<{ build: any; refreshBuild: () => void }>) {
 | 
					}: Readonly<{ build: any; refreshBuild: () => void }>) {
 | 
				
			||||||
  const user = useUserState();
 | 
					  const user = useUserState();
 | 
				
			||||||
 | 
					  const navigate = useNavigate();
 | 
				
			||||||
  const table = useTable('build-outputs');
 | 
					  const table = useTable('build-outputs');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const buildId: number = useMemo(() => {
 | 
					  const buildId: number = useMemo(() => {
 | 
				
			||||||
@@ -381,6 +383,12 @@ export default function BuildOutputTable({
 | 
				
			|||||||
  const rowActions = useCallback(
 | 
					  const rowActions = useCallback(
 | 
				
			||||||
    (record: any): RowAction[] => {
 | 
					    (record: any): RowAction[] => {
 | 
				
			||||||
      return [
 | 
					      return [
 | 
				
			||||||
 | 
					        RowViewAction({
 | 
				
			||||||
 | 
					          title: t`View Build Output`,
 | 
				
			||||||
 | 
					          modelId: record.pk,
 | 
				
			||||||
 | 
					          modelType: ModelType.stockitem,
 | 
				
			||||||
 | 
					          navigate: navigate
 | 
				
			||||||
 | 
					        }),
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
          title: t`Allocate`,
 | 
					          title: t`Allocate`,
 | 
				
			||||||
          tooltip: t`Allocate stock to build output`,
 | 
					          tooltip: t`Allocate stock to build output`,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,6 @@
 | 
				
			|||||||
import { Trans, t } from '@lingui/macro';
 | 
					import { Trans, t } from '@lingui/macro';
 | 
				
			||||||
import { Alert, Badge, Stack, Text } from '@mantine/core';
 | 
					import { Alert, Badge, Stack, Text } from '@mantine/core';
 | 
				
			||||||
import { IconArrowRight, IconLock } from '@tabler/icons-react';
 | 
					import { IconLock } from '@tabler/icons-react';
 | 
				
			||||||
import { ReactNode, useCallback, useMemo, useState } from 'react';
 | 
					import { ReactNode, useCallback, useMemo, useState } from 'react';
 | 
				
			||||||
import { useNavigate } from 'react-router-dom';
 | 
					import { useNavigate } from 'react-router-dom';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -22,7 +22,12 @@ import { TableColumn } from '../Column';
 | 
				
			|||||||
import { BooleanColumn, DescriptionColumn } from '../ColumnRenderers';
 | 
					import { BooleanColumn, DescriptionColumn } from '../ColumnRenderers';
 | 
				
			||||||
import { TableFilter } from '../Filter';
 | 
					import { TableFilter } from '../Filter';
 | 
				
			||||||
import { InvenTreeTable } from '../InvenTreeTable';
 | 
					import { InvenTreeTable } from '../InvenTreeTable';
 | 
				
			||||||
import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
 | 
					import {
 | 
				
			||||||
 | 
					  RowAction,
 | 
				
			||||||
 | 
					  RowDeleteAction,
 | 
				
			||||||
 | 
					  RowEditAction,
 | 
				
			||||||
 | 
					  RowViewAction
 | 
				
			||||||
 | 
					} from '../RowActions';
 | 
				
			||||||
import { TableHoverCard } from '../TableHoverCard';
 | 
					import { TableHoverCard } from '../TableHoverCard';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function PartTestTemplateTable({
 | 
					export default function PartTestTemplateTable({
 | 
				
			||||||
@@ -199,13 +204,12 @@ export default function PartTestTemplateTable({
 | 
				
			|||||||
      if (record.part != partId) {
 | 
					      if (record.part != partId) {
 | 
				
			||||||
        // This test is defined for a parent part
 | 
					        // This test is defined for a parent part
 | 
				
			||||||
        return [
 | 
					        return [
 | 
				
			||||||
          {
 | 
					          RowViewAction({
 | 
				
			||||||
            icon: <IconArrowRight />,
 | 
					 | 
				
			||||||
            title: t`View Parent Part`,
 | 
					            title: t`View Parent Part`,
 | 
				
			||||||
            onClick: () => {
 | 
					            modelType: ModelType.part,
 | 
				
			||||||
              navigate(getDetailUrl(ModelType.part, record.part));
 | 
					            modelId: record.part,
 | 
				
			||||||
            }
 | 
					            navigate: navigate
 | 
				
			||||||
          }
 | 
					          })
 | 
				
			||||||
        ];
 | 
					        ];
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,11 +1,9 @@
 | 
				
			|||||||
import { t } from '@lingui/macro';
 | 
					import { t } from '@lingui/macro';
 | 
				
			||||||
import { useCallback, useMemo, useState } from 'react';
 | 
					import { useCallback, useMemo, useState } from 'react';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
 | 
					import { formatDate } from '../../defaults/formatters';
 | 
				
			||||||
import { YesNoButton } from '../../components/buttons/YesNoButton';
 | 
					 | 
				
			||||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
 | 
					import { ApiEndpoints } from '../../enums/ApiEndpoints';
 | 
				
			||||||
import { ModelType } from '../../enums/ModelType';
 | 
					import { ModelType } from '../../enums/ModelType';
 | 
				
			||||||
import { UserRoles } from '../../enums/Roles';
 | 
					 | 
				
			||||||
import { useSalesOrderAllocationFields } from '../../forms/SalesOrderForms';
 | 
					import { useSalesOrderAllocationFields } from '../../forms/SalesOrderForms';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  useDeleteApiFormModal,
 | 
					  useDeleteApiFormModal,
 | 
				
			||||||
@@ -16,7 +14,6 @@ import { apiUrl } from '../../states/ApiState';
 | 
				
			|||||||
import { useUserState } from '../../states/UserState';
 | 
					import { useUserState } from '../../states/UserState';
 | 
				
			||||||
import { TableColumn } from '../Column';
 | 
					import { TableColumn } from '../Column';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  DateColumn,
 | 
					 | 
				
			||||||
  LocationColumn,
 | 
					  LocationColumn,
 | 
				
			||||||
  PartColumn,
 | 
					  PartColumn,
 | 
				
			||||||
  ReferenceColumn,
 | 
					  ReferenceColumn,
 | 
				
			||||||
@@ -30,27 +27,44 @@ export default function SalesOrderAllocationTable({
 | 
				
			|||||||
  partId,
 | 
					  partId,
 | 
				
			||||||
  stockId,
 | 
					  stockId,
 | 
				
			||||||
  orderId,
 | 
					  orderId,
 | 
				
			||||||
 | 
					  lineItemId,
 | 
				
			||||||
  shipmentId,
 | 
					  shipmentId,
 | 
				
			||||||
  showPartInfo,
 | 
					  showPartInfo,
 | 
				
			||||||
  showOrderInfo,
 | 
					  showOrderInfo,
 | 
				
			||||||
  allowEdit,
 | 
					  allowEdit,
 | 
				
			||||||
 | 
					  isSubTable,
 | 
				
			||||||
  modelTarget,
 | 
					  modelTarget,
 | 
				
			||||||
  modelField
 | 
					  modelField
 | 
				
			||||||
}: Readonly<{
 | 
					}: Readonly<{
 | 
				
			||||||
  partId?: number;
 | 
					  partId?: number;
 | 
				
			||||||
  stockId?: number;
 | 
					  stockId?: number;
 | 
				
			||||||
  orderId?: number;
 | 
					  orderId?: number;
 | 
				
			||||||
 | 
					  lineItemId?: number;
 | 
				
			||||||
  shipmentId?: number;
 | 
					  shipmentId?: number;
 | 
				
			||||||
  showPartInfo?: boolean;
 | 
					  showPartInfo?: boolean;
 | 
				
			||||||
  showOrderInfo?: boolean;
 | 
					  showOrderInfo?: boolean;
 | 
				
			||||||
  allowEdit?: boolean;
 | 
					  allowEdit?: boolean;
 | 
				
			||||||
 | 
					  isSubTable?: boolean;
 | 
				
			||||||
  modelTarget?: ModelType;
 | 
					  modelTarget?: ModelType;
 | 
				
			||||||
  modelField?: string;
 | 
					  modelField?: string;
 | 
				
			||||||
}>) {
 | 
					}>) {
 | 
				
			||||||
  const user = useUserState();
 | 
					  const user = useUserState();
 | 
				
			||||||
  const table = useTable(
 | 
					
 | 
				
			||||||
    !!partId ? 'salesorderallocations-part' : 'salesorderallocations'
 | 
					  const tableId = useMemo(() => {
 | 
				
			||||||
  );
 | 
					    let id: string = 'salesorderallocations';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!!partId) {
 | 
				
			||||||
 | 
					      id += '-part';
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (isSubTable) {
 | 
				
			||||||
 | 
					      id += '-sub';
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return id;
 | 
				
			||||||
 | 
					  }, [partId, isSubTable]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const table = useTable(tableId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const tableFilters: TableFilter[] = useMemo(() => {
 | 
					  const tableFilters: TableFilter[] = useMemo(() => {
 | 
				
			||||||
    let filters: TableFilter[] = [
 | 
					    let filters: TableFilter[] = [
 | 
				
			||||||
@@ -58,6 +72,11 @@ export default function SalesOrderAllocationTable({
 | 
				
			|||||||
        name: 'outstanding',
 | 
					        name: 'outstanding',
 | 
				
			||||||
        label: t`Outstanding`,
 | 
					        label: t`Outstanding`,
 | 
				
			||||||
        description: t`Show outstanding allocations`
 | 
					        description: t`Show outstanding allocations`
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        name: 'assigned_to_shipment',
 | 
				
			||||||
 | 
					        label: t`Assigned to Shipment`,
 | 
				
			||||||
 | 
					        description: t`Show allocations assigned to a shipment`
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    ];
 | 
					    ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -119,6 +138,7 @@ export default function SalesOrderAllocationTable({
 | 
				
			|||||||
        accessor: 'available',
 | 
					        accessor: 'available',
 | 
				
			||||||
        title: t`Available Quantity`,
 | 
					        title: t`Available Quantity`,
 | 
				
			||||||
        sortable: false,
 | 
					        sortable: false,
 | 
				
			||||||
 | 
					        hidden: isSubTable,
 | 
				
			||||||
        render: (record: any) => record?.item_detail?.quantity
 | 
					        render: (record: any) => record?.item_detail?.quantity
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      {
 | 
					      {
 | 
				
			||||||
@@ -135,30 +155,36 @@ export default function SalesOrderAllocationTable({
 | 
				
			|||||||
        accessor: 'shipment_detail.reference',
 | 
					        accessor: 'shipment_detail.reference',
 | 
				
			||||||
        title: t`Shipment`,
 | 
					        title: t`Shipment`,
 | 
				
			||||||
        switchable: true,
 | 
					        switchable: true,
 | 
				
			||||||
        sortable: false
 | 
					        sortable: false,
 | 
				
			||||||
 | 
					        render: (record: any) => {
 | 
				
			||||||
 | 
					          return record.shipment_detail?.reference ?? t`No shipment`;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      DateColumn({
 | 
					 | 
				
			||||||
        accessor: 'shipment_detail.shipment_date',
 | 
					 | 
				
			||||||
        title: t`Shipment Date`,
 | 
					 | 
				
			||||||
        switchable: true,
 | 
					 | 
				
			||||||
        sortable: false
 | 
					 | 
				
			||||||
      }),
 | 
					 | 
				
			||||||
      {
 | 
					      {
 | 
				
			||||||
        accessor: 'shipment_date',
 | 
					        accessor: 'shipment_date',
 | 
				
			||||||
        title: t`Shipped`,
 | 
					        title: t`Shipment Date`,
 | 
				
			||||||
        switchable: true,
 | 
					        switchable: true,
 | 
				
			||||||
        sortable: false,
 | 
					        sortable: true,
 | 
				
			||||||
        render: (record: any) => (
 | 
					        render: (record: any) => {
 | 
				
			||||||
          <YesNoButton value={!!record.shipment_detail?.shipment_date} />
 | 
					          if (record.shipment_detail?.shipment_date) {
 | 
				
			||||||
        )
 | 
					            return formatDate(record.shipment_detail.shipment_date);
 | 
				
			||||||
 | 
					          } else if (record.shipment) {
 | 
				
			||||||
 | 
					            return t`Not shipped`;
 | 
				
			||||||
 | 
					          } else {
 | 
				
			||||||
 | 
					            return t`No shipment`;
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    ];
 | 
					    ];
 | 
				
			||||||
  }, []);
 | 
					  }, [showOrderInfo, showPartInfo, isSubTable]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [selectedAllocation, setSelectedAllocation] = useState<number>(0);
 | 
					  const [selectedAllocation, setSelectedAllocation] = useState<number>(0);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const [selectedShipment, setSelectedShipment] = useState<any | null>(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const editAllocationFields = useSalesOrderAllocationFields({
 | 
					  const editAllocationFields = useSalesOrderAllocationFields({
 | 
				
			||||||
    shipmentId: shipmentId
 | 
					    orderId: orderId,
 | 
				
			||||||
 | 
					    shipment: selectedShipment
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const editAllocation = useEditApiFormModal({
 | 
					  const editAllocation = useEditApiFormModal({
 | 
				
			||||||
@@ -166,14 +192,14 @@ export default function SalesOrderAllocationTable({
 | 
				
			|||||||
    pk: selectedAllocation,
 | 
					    pk: selectedAllocation,
 | 
				
			||||||
    fields: editAllocationFields,
 | 
					    fields: editAllocationFields,
 | 
				
			||||||
    title: t`Edit Allocation`,
 | 
					    title: t`Edit Allocation`,
 | 
				
			||||||
    table: table
 | 
					    onFormSuccess: () => table.refreshTable()
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const deleteAllocation = useDeleteApiFormModal({
 | 
					  const deleteAllocation = useDeleteApiFormModal({
 | 
				
			||||||
    url: ApiEndpoints.sales_order_allocation_list,
 | 
					    url: ApiEndpoints.sales_order_allocation_list,
 | 
				
			||||||
    pk: selectedAllocation,
 | 
					    pk: selectedAllocation,
 | 
				
			||||||
    title: t`Delete Allocation`,
 | 
					    title: t`Delete Allocation`,
 | 
				
			||||||
    table: table
 | 
					    onFormSuccess: () => table.refreshTable()
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const rowActions = useCallback(
 | 
					  const rowActions = useCallback(
 | 
				
			||||||
@@ -190,6 +216,7 @@ export default function SalesOrderAllocationTable({
 | 
				
			|||||||
          tooltip: t`Edit Allocation`,
 | 
					          tooltip: t`Edit Allocation`,
 | 
				
			||||||
          onClick: () => {
 | 
					          onClick: () => {
 | 
				
			||||||
            setSelectedAllocation(record.pk);
 | 
					            setSelectedAllocation(record.pk);
 | 
				
			||||||
 | 
					            setSelectedShipment(record.shipment);
 | 
				
			||||||
            editAllocation.open();
 | 
					            editAllocation.open();
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
        }),
 | 
					        }),
 | 
				
			||||||
@@ -227,11 +254,18 @@ export default function SalesOrderAllocationTable({
 | 
				
			|||||||
            order_detail: showOrderInfo ?? false,
 | 
					            order_detail: showOrderInfo ?? false,
 | 
				
			||||||
            item_detail: true,
 | 
					            item_detail: true,
 | 
				
			||||||
            location_detail: true,
 | 
					            location_detail: true,
 | 
				
			||||||
 | 
					            line: lineItemId,
 | 
				
			||||||
            part: partId,
 | 
					            part: partId,
 | 
				
			||||||
            order: orderId,
 | 
					            order: orderId,
 | 
				
			||||||
            shipment: shipmentId,
 | 
					            shipment: shipmentId,
 | 
				
			||||||
            item: stockId
 | 
					            item: stockId
 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
 | 
					          enableSearch: !isSubTable,
 | 
				
			||||||
 | 
					          enableRefresh: !isSubTable,
 | 
				
			||||||
 | 
					          enableColumnSwitching: !isSubTable,
 | 
				
			||||||
 | 
					          enableFilters: !isSubTable,
 | 
				
			||||||
 | 
					          enableDownload: !isSubTable,
 | 
				
			||||||
 | 
					          minHeight: isSubTable ? 100 : undefined,
 | 
				
			||||||
          rowActions: rowActions,
 | 
					          rowActions: rowActions,
 | 
				
			||||||
          tableActions: tableActions,
 | 
					          tableActions: tableActions,
 | 
				
			||||||
          tableFilters: tableFilters,
 | 
					          tableFilters: tableFilters,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,13 +1,17 @@
 | 
				
			|||||||
import { t } from '@lingui/macro';
 | 
					import { t } from '@lingui/macro';
 | 
				
			||||||
import { Text } from '@mantine/core';
 | 
					import { ActionIcon, Group, Text } from '@mantine/core';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  IconArrowRight,
 | 
					  IconArrowRight,
 | 
				
			||||||
 | 
					  IconChevronDown,
 | 
				
			||||||
 | 
					  IconChevronRight,
 | 
				
			||||||
  IconHash,
 | 
					  IconHash,
 | 
				
			||||||
  IconShoppingCart,
 | 
					  IconShoppingCart,
 | 
				
			||||||
  IconSquareArrowRight,
 | 
					  IconSquareArrowRight,
 | 
				
			||||||
  IconTools
 | 
					  IconTools
 | 
				
			||||||
} from '@tabler/icons-react';
 | 
					} from '@tabler/icons-react';
 | 
				
			||||||
 | 
					import { DataTableRowExpansionProps } from 'mantine-datatable';
 | 
				
			||||||
import { ReactNode, useCallback, useMemo, useState } from 'react';
 | 
					import { ReactNode, useCallback, useMemo, useState } from 'react';
 | 
				
			||||||
 | 
					import { useNavigate } from 'react-router-dom';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { ActionButton } from '../../components/buttons/ActionButton';
 | 
					import { ActionButton } from '../../components/buttons/ActionButton';
 | 
				
			||||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
 | 
					import { AddItemButton } from '../../components/buttons/AddItemButton';
 | 
				
			||||||
@@ -39,9 +43,11 @@ import {
 | 
				
			|||||||
  RowAction,
 | 
					  RowAction,
 | 
				
			||||||
  RowDeleteAction,
 | 
					  RowDeleteAction,
 | 
				
			||||||
  RowDuplicateAction,
 | 
					  RowDuplicateAction,
 | 
				
			||||||
  RowEditAction
 | 
					  RowEditAction,
 | 
				
			||||||
 | 
					  RowViewAction
 | 
				
			||||||
} from '../RowActions';
 | 
					} from '../RowActions';
 | 
				
			||||||
import { TableHoverCard } from '../TableHoverCard';
 | 
					import { TableHoverCard } from '../TableHoverCard';
 | 
				
			||||||
 | 
					import SalesOrderAllocationTable from './SalesOrderAllocationTable';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function SalesOrderLineItemTable({
 | 
					export default function SalesOrderLineItemTable({
 | 
				
			||||||
  orderId,
 | 
					  orderId,
 | 
				
			||||||
@@ -54,6 +60,7 @@ export default function SalesOrderLineItemTable({
 | 
				
			|||||||
  customerId: number;
 | 
					  customerId: number;
 | 
				
			||||||
  editable: boolean;
 | 
					  editable: boolean;
 | 
				
			||||||
}>) {
 | 
					}>) {
 | 
				
			||||||
 | 
					  const navigate = useNavigate();
 | 
				
			||||||
  const user = useUserState();
 | 
					  const user = useUserState();
 | 
				
			||||||
  const table = useTable('sales-order-line-item');
 | 
					  const table = useTable('sales-order-line-item');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -63,7 +70,24 @@ export default function SalesOrderLineItemTable({
 | 
				
			|||||||
        accessor: 'part',
 | 
					        accessor: 'part',
 | 
				
			||||||
        sortable: true,
 | 
					        sortable: true,
 | 
				
			||||||
        switchable: false,
 | 
					        switchable: false,
 | 
				
			||||||
        render: (record: any) => PartColumn({ part: record?.part_detail })
 | 
					        render: (record: any) => {
 | 
				
			||||||
 | 
					          return (
 | 
				
			||||||
 | 
					            <Group wrap="nowrap">
 | 
				
			||||||
 | 
					              <ActionIcon
 | 
				
			||||||
 | 
					                size="sm"
 | 
				
			||||||
 | 
					                variant="transparent"
 | 
				
			||||||
 | 
					                disabled={!record.allocated}
 | 
				
			||||||
 | 
					              >
 | 
				
			||||||
 | 
					                {table.isRowExpanded(record.pk) ? (
 | 
				
			||||||
 | 
					                  <IconChevronDown />
 | 
				
			||||||
 | 
					                ) : (
 | 
				
			||||||
 | 
					                  <IconChevronRight />
 | 
				
			||||||
 | 
					                )}
 | 
				
			||||||
 | 
					              </ActionIcon>
 | 
				
			||||||
 | 
					              <PartColumn part={record.part_detail} />
 | 
				
			||||||
 | 
					            </Group>
 | 
				
			||||||
 | 
					          );
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      {
 | 
					      {
 | 
				
			||||||
        accessor: 'part_detail.IPN',
 | 
					        accessor: 'part_detail.IPN',
 | 
				
			||||||
@@ -189,7 +213,7 @@ export default function SalesOrderLineItemTable({
 | 
				
			|||||||
        accessor: 'link'
 | 
					        accessor: 'link'
 | 
				
			||||||
      })
 | 
					      })
 | 
				
			||||||
    ];
 | 
					    ];
 | 
				
			||||||
  }, []);
 | 
					  }, [table.isRowExpanded]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [selectedLine, setSelectedLine] = useState<number>(0);
 | 
					  const [selectedLine, setSelectedLine] = useState<number>(0);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -318,6 +342,13 @@ export default function SalesOrderLineItemTable({
 | 
				
			|||||||
      const allocated = (record?.allocated ?? 0) > (record?.quantity ?? 0);
 | 
					      const allocated = (record?.allocated ?? 0) > (record?.quantity ?? 0);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      return [
 | 
					      return [
 | 
				
			||||||
 | 
					        RowViewAction({
 | 
				
			||||||
 | 
					          title: t`View Part`,
 | 
				
			||||||
 | 
					          modelType: ModelType.part,
 | 
				
			||||||
 | 
					          modelId: record.part,
 | 
				
			||||||
 | 
					          navigate: navigate,
 | 
				
			||||||
 | 
					          hidden: !user.hasViewRole(UserRoles.part)
 | 
				
			||||||
 | 
					        }),
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
          hidden:
 | 
					          hidden:
 | 
				
			||||||
            allocated ||
 | 
					            allocated ||
 | 
				
			||||||
@@ -398,9 +429,32 @@ export default function SalesOrderLineItemTable({
 | 
				
			|||||||
        })
 | 
					        })
 | 
				
			||||||
      ];
 | 
					      ];
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    [user, editable]
 | 
					    [navigate, user, editable]
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Control row expansion
 | 
				
			||||||
 | 
					  const rowExpansion: DataTableRowExpansionProps<any> = useMemo(() => {
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					      allowMultiple: true,
 | 
				
			||||||
 | 
					      expandable: ({ record }: { record: any }) => {
 | 
				
			||||||
 | 
					        return table.isRowExpanded(record.pk) || record.allocated > 0;
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      content: ({ record }: { record: any }) => {
 | 
				
			||||||
 | 
					        return (
 | 
				
			||||||
 | 
					          <SalesOrderAllocationTable
 | 
				
			||||||
 | 
					            showOrderInfo={false}
 | 
				
			||||||
 | 
					            showPartInfo={false}
 | 
				
			||||||
 | 
					            orderId={orderId}
 | 
				
			||||||
 | 
					            lineItemId={record.pk}
 | 
				
			||||||
 | 
					            partId={record.part}
 | 
				
			||||||
 | 
					            allowEdit
 | 
				
			||||||
 | 
					            isSubTable
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  }, [orderId, table.isRowExpanded]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <>
 | 
					    <>
 | 
				
			||||||
      {editLine.modal}
 | 
					      {editLine.modal}
 | 
				
			||||||
@@ -423,8 +477,7 @@ export default function SalesOrderLineItemTable({
 | 
				
			|||||||
          rowActions: rowActions,
 | 
					          rowActions: rowActions,
 | 
				
			||||||
          tableActions: tableActions,
 | 
					          tableActions: tableActions,
 | 
				
			||||||
          tableFilters: tableFilters,
 | 
					          tableFilters: tableFilters,
 | 
				
			||||||
          modelType: ModelType.part,
 | 
					          rowExpansion: rowExpansion
 | 
				
			||||||
          modelField: 'part'
 | 
					 | 
				
			||||||
        }}
 | 
					        }}
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
    </>
 | 
					    </>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,5 @@
 | 
				
			|||||||
import { t } from '@lingui/macro';
 | 
					import { t } from '@lingui/macro';
 | 
				
			||||||
import { IconArrowRight, IconTruckDelivery } from '@tabler/icons-react';
 | 
					import { IconTruckDelivery } from '@tabler/icons-react';
 | 
				
			||||||
import { useCallback, useMemo, useState } from 'react';
 | 
					import { useCallback, useMemo, useState } from 'react';
 | 
				
			||||||
import { useNavigate } from 'react-router-dom';
 | 
					import { useNavigate } from 'react-router-dom';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -12,9 +12,6 @@ import {
 | 
				
			|||||||
  useSalesOrderShipmentCompleteFields,
 | 
					  useSalesOrderShipmentCompleteFields,
 | 
				
			||||||
  useSalesOrderShipmentFields
 | 
					  useSalesOrderShipmentFields
 | 
				
			||||||
} from '../../forms/SalesOrderForms';
 | 
					} from '../../forms/SalesOrderForms';
 | 
				
			||||||
import { navigateToLink } from '../../functions/navigation';
 | 
					 | 
				
			||||||
import { notYetImplemented } from '../../functions/notifications';
 | 
					 | 
				
			||||||
import { getDetailUrl } from '../../functions/urls';
 | 
					 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  useCreateApiFormModal,
 | 
					  useCreateApiFormModal,
 | 
				
			||||||
  useDeleteApiFormModal,
 | 
					  useDeleteApiFormModal,
 | 
				
			||||||
@@ -24,15 +21,15 @@ import { useTable } from '../../hooks/UseTable';
 | 
				
			|||||||
import { apiUrl } from '../../states/ApiState';
 | 
					import { apiUrl } from '../../states/ApiState';
 | 
				
			||||||
import { useUserState } from '../../states/UserState';
 | 
					import { useUserState } from '../../states/UserState';
 | 
				
			||||||
import { TableColumn } from '../Column';
 | 
					import { TableColumn } from '../Column';
 | 
				
			||||||
import {
 | 
					import { DateColumn, LinkColumn } from '../ColumnRenderers';
 | 
				
			||||||
  BooleanColumn,
 | 
					 | 
				
			||||||
  DateColumn,
 | 
					 | 
				
			||||||
  LinkColumn,
 | 
					 | 
				
			||||||
  NoteColumn
 | 
					 | 
				
			||||||
} from '../ColumnRenderers';
 | 
					 | 
				
			||||||
import { TableFilter } from '../Filter';
 | 
					import { TableFilter } from '../Filter';
 | 
				
			||||||
import { InvenTreeTable } from '../InvenTreeTable';
 | 
					import { InvenTreeTable } from '../InvenTreeTable';
 | 
				
			||||||
import { RowAction, RowCancelAction, RowEditAction } from '../RowActions';
 | 
					import {
 | 
				
			||||||
 | 
					  RowAction,
 | 
				
			||||||
 | 
					  RowCancelAction,
 | 
				
			||||||
 | 
					  RowEditAction,
 | 
				
			||||||
 | 
					  RowViewAction
 | 
				
			||||||
 | 
					} from '../RowActions';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function SalesOrderShipmentTable({
 | 
					export default function SalesOrderShipmentTable({
 | 
				
			||||||
  orderId
 | 
					  orderId
 | 
				
			||||||
@@ -135,17 +132,12 @@ export default function SalesOrderShipmentTable({
 | 
				
			|||||||
      const shipped: boolean = !!record.shipment_date;
 | 
					      const shipped: boolean = !!record.shipment_date;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      return [
 | 
					      return [
 | 
				
			||||||
        {
 | 
					        RowViewAction({
 | 
				
			||||||
          title: t`View Shipment`,
 | 
					          title: t`View Shipment`,
 | 
				
			||||||
          icon: <IconArrowRight />,
 | 
					          modelType: ModelType.salesordershipment,
 | 
				
			||||||
          onClick: (event: any) => {
 | 
					          modelId: record.pk,
 | 
				
			||||||
            navigateToLink(
 | 
					          navigate: navigate
 | 
				
			||||||
              getDetailUrl(ModelType.salesordershipment, record.pk),
 | 
					        }),
 | 
				
			||||||
              navigate,
 | 
					 | 
				
			||||||
              event
 | 
					 | 
				
			||||||
            );
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
          hidden: shipped || !user.hasChangeRole(UserRoles.sales_order),
 | 
					          hidden: shipped || !user.hasChangeRole(UserRoles.sales_order),
 | 
				
			||||||
          title: t`Complete Shipment`,
 | 
					          title: t`Complete Shipment`,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,6 +3,7 @@ import { useMemo } from 'react';
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
 | 
					import { AddItemButton } from '../../components/buttons/AddItemButton';
 | 
				
			||||||
import { Thumbnail } from '../../components/images/Thumbnail';
 | 
					import { Thumbnail } from '../../components/images/Thumbnail';
 | 
				
			||||||
 | 
					import { ProgressBar } from '../../components/items/ProgressBar';
 | 
				
			||||||
import { formatCurrency } from '../../defaults/formatters';
 | 
					import { formatCurrency } from '../../defaults/formatters';
 | 
				
			||||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
 | 
					import { ApiEndpoints } from '../../enums/ApiEndpoints';
 | 
				
			||||||
import { ModelType } from '../../enums/ModelType';
 | 
					import { ModelType } from '../../enums/ModelType';
 | 
				
			||||||
@@ -138,6 +139,17 @@ export function SalesOrderTable({
 | 
				
			|||||||
      },
 | 
					      },
 | 
				
			||||||
      DescriptionColumn({}),
 | 
					      DescriptionColumn({}),
 | 
				
			||||||
      LineItemsProgressColumn(),
 | 
					      LineItemsProgressColumn(),
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        accessor: 'shipments_count',
 | 
				
			||||||
 | 
					        title: t`Shipments`,
 | 
				
			||||||
 | 
					        render: (record: any) => (
 | 
				
			||||||
 | 
					          <ProgressBar
 | 
				
			||||||
 | 
					            progressLabel
 | 
				
			||||||
 | 
					            value={record.completed_shipments_count}
 | 
				
			||||||
 | 
					            maximum={record.shipments_count}
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
      StatusColumn({ model: ModelType.salesorder }),
 | 
					      StatusColumn({ model: ModelType.salesorder }),
 | 
				
			||||||
      ProjectCodeColumn({}),
 | 
					      ProjectCodeColumn({}),
 | 
				
			||||||
      CreationDateColumn({}),
 | 
					      CreationDateColumn({}),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -122,8 +122,6 @@ test('Sales Orders - Shipments', async ({ page }) => {
 | 
				
			|||||||
  await page.getByLabel('number-field-quantity').fill('123');
 | 
					  await page.getByLabel('number-field-quantity').fill('123');
 | 
				
			||||||
  await page.getByLabel('related-field-stock_item').click();
 | 
					  await page.getByLabel('related-field-stock_item').click();
 | 
				
			||||||
  await page.getByText('Quantity: 42').click();
 | 
					  await page.getByText('Quantity: 42').click();
 | 
				
			||||||
  await page.getByRole('button', { name: 'Submit' }).click();
 | 
					 | 
				
			||||||
  await page.getByText('This field is required.').waitFor();
 | 
					 | 
				
			||||||
  await page.getByRole('button', { name: 'Cancel' }).click();
 | 
					  await page.getByRole('button', { name: 'Cancel' }).click();
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -55,12 +55,18 @@ test('Scanning (Part)', async ({ page }) => {
 | 
				
			|||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
test('Scanning (Stockitem)', async ({ page }) => {
 | 
					test('Scanning (Stockitem)', async ({ page }) => {
 | 
				
			||||||
 | 
					  // TODO: Come back to here and re-enable this test
 | 
				
			||||||
 | 
					  // TODO: Something is wrong with the test, it's not working as expected
 | 
				
			||||||
 | 
					  // TODO: The barcode scanning page needs some attention in general
 | 
				
			||||||
 | 
					  /*
 | 
				
			||||||
 | 
					   * TODO: 2024-11-08 : https://github.com/inventree/InvenTree/pull/8445
 | 
				
			||||||
  await defaultScanTest(page, '{"stockitem": 408}');
 | 
					  await defaultScanTest(page, '{"stockitem": 408}');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // stockitem: 408
 | 
					  // stockitem: 408
 | 
				
			||||||
  await page.getByText('1551ABK').waitFor();
 | 
					  await page.getByText('1551ABK').waitFor();
 | 
				
			||||||
  await page.getByText('Quantity: 100').waitFor();
 | 
					  await page.getByText('Quantity: 100').waitFor();
 | 
				
			||||||
  await page.getByRole('cell', { name: 'Quantity: 100' }).waitFor();
 | 
					  await page.getByRole('cell', { name: 'Quantity: 100' }).waitFor();
 | 
				
			||||||
 | 
					  */
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
test('Scanning (StockLocation)', async ({ page }) => {
 | 
					test('Scanning (StockLocation)', async ({ page }) => {
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user