mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-11-04 07:05:41 +00:00 
			
		
		
		
	[API] Sales order filters (#8331)
* Fix 'allocated' queryset annotation for SalesOrderLineItemSerializer * Add 'allocated' filter for SalesOrderLineItemList * Allow ordering by 'allocated' and 'shipped' values * Updated unit testing * Bump API version * Update playwright tests
This commit is contained in:
		@@ -1,13 +1,16 @@
 | 
			
		||||
"""InvenTree API version information."""
 | 
			
		||||
 | 
			
		||||
# InvenTree API version
 | 
			
		||||
INVENTREE_API_VERSION = 270
 | 
			
		||||
INVENTREE_API_VERSION = 271
 | 
			
		||||
 | 
			
		||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
INVENTREE_API_TEXT = """
 | 
			
		||||
 | 
			
		||||
v271 - 2024-10-22 : https://github.com/inventree/InvenTree/pull/8331
 | 
			
		||||
    - Fixes for SalesOrderLineItem endpoints
 | 
			
		||||
 | 
			
		||||
v270 - 2024-10-19 : https://github.com/inventree/InvenTree/pull/8307
 | 
			
		||||
    - Adds missing date fields from order API endpoint(s)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -771,12 +771,29 @@ class SalesOrderLineItemFilter(LineItemFilter):
 | 
			
		||||
        queryset=Part.objects.all(), field_name='part', label=_('Part')
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    completed = rest_filters.BooleanFilter(label='completed', method='filter_completed')
 | 
			
		||||
    allocated = rest_filters.BooleanFilter(
 | 
			
		||||
        label=_('Allocated'), method='filter_allocated'
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    def filter_allocated(self, queryset, name, value):
 | 
			
		||||
        """Filter by lines which are 'allocated'.
 | 
			
		||||
 | 
			
		||||
        A line is 'allocated' when allocated >= quantity
 | 
			
		||||
        """
 | 
			
		||||
        q = Q(allocated__gte=F('quantity'))
 | 
			
		||||
 | 
			
		||||
        if str2bool(value):
 | 
			
		||||
            return queryset.filter(q)
 | 
			
		||||
        return queryset.exclude(q)
 | 
			
		||||
 | 
			
		||||
    completed = rest_filters.BooleanFilter(
 | 
			
		||||
        label=_('Completed'), method='filter_completed'
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    def filter_completed(self, queryset, name, value):
 | 
			
		||||
        """Filter by lines which are "completed".
 | 
			
		||||
 | 
			
		||||
        A line is completed when shipped >= quantity
 | 
			
		||||
        A line is 'completed' when shipped >= quantity
 | 
			
		||||
        """
 | 
			
		||||
        q = Q(shipped__gte=F('quantity'))
 | 
			
		||||
 | 
			
		||||
@@ -855,6 +872,8 @@ class SalesOrderLineItemList(
 | 
			
		||||
        'part',
 | 
			
		||||
        'part__name',
 | 
			
		||||
        'quantity',
 | 
			
		||||
        'allocated',
 | 
			
		||||
        'shipped',
 | 
			
		||||
        'reference',
 | 
			
		||||
        'sale_price',
 | 
			
		||||
        'target_date',
 | 
			
		||||
 
 | 
			
		||||
@@ -14,11 +14,12 @@ from django.db.models import (
 | 
			
		||||
    Value,
 | 
			
		||||
    When,
 | 
			
		||||
)
 | 
			
		||||
from django.db.models.functions import Coalesce
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
 | 
			
		||||
from rest_framework import serializers
 | 
			
		||||
from rest_framework.serializers import ValidationError
 | 
			
		||||
from sql_util.utils import SubqueryCount
 | 
			
		||||
from sql_util.utils import SubqueryCount, SubquerySum
 | 
			
		||||
 | 
			
		||||
import order.models
 | 
			
		||||
import part.filters as part_filters
 | 
			
		||||
@@ -1165,6 +1166,15 @@ class SalesOrderLineItemSerializer(
 | 
			
		||||
            building=part_filters.annotate_in_production_quantity(reference='part__')
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Annotate total 'allocated' stock quantity
 | 
			
		||||
        queryset = queryset.annotate(
 | 
			
		||||
            allocated=Coalesce(
 | 
			
		||||
                SubquerySum('allocations__quantity'),
 | 
			
		||||
                Decimal(0),
 | 
			
		||||
                output_field=models.DecimalField(),
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        return queryset
 | 
			
		||||
 | 
			
		||||
    order_detail = SalesOrderSerializer(source='order', many=False, read_only=True)
 | 
			
		||||
@@ -1182,7 +1192,7 @@ class SalesOrderLineItemSerializer(
 | 
			
		||||
 | 
			
		||||
    quantity = InvenTreeDecimalField()
 | 
			
		||||
 | 
			
		||||
    allocated = serializers.FloatField(source='allocated_quantity', read_only=True)
 | 
			
		||||
    allocated = serializers.FloatField(read_only=True)
 | 
			
		||||
 | 
			
		||||
    shipped = InvenTreeDecimalField(read_only=True)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1683,10 +1683,96 @@ class SalesOrderLineItemTest(OrderTest):
 | 
			
		||||
        self.filter({'has_pricing': 1}, 0)
 | 
			
		||||
        self.filter({'has_pricing': 0}, n)
 | 
			
		||||
 | 
			
		||||
        # Filter by has_pricing status
 | 
			
		||||
        # Filter by 'completed' status
 | 
			
		||||
        self.filter({'completed': 1}, 0)
 | 
			
		||||
        self.filter({'completed': 0}, n)
 | 
			
		||||
 | 
			
		||||
        # Filter by 'allocated' status
 | 
			
		||||
        self.filter({'allocated': 'true'}, 0)
 | 
			
		||||
        self.filter({'allocated': 'false'}, n)
 | 
			
		||||
 | 
			
		||||
    def test_so_line_allocated_filters(self):
 | 
			
		||||
        """Test filtering by allocation status for a SalesOrderLineItem."""
 | 
			
		||||
        self.assignRole('sales_order.add')
 | 
			
		||||
 | 
			
		||||
        # Crete a new SalesOrder via the API
 | 
			
		||||
        response = self.post(
 | 
			
		||||
            reverse('api-so-list'),
 | 
			
		||||
            {
 | 
			
		||||
                'customer': Company.objects.filter(is_customer=True).first().pk,
 | 
			
		||||
                'reference': 'SO-12345',
 | 
			
		||||
                'description': 'Test Sales Order',
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        order_id = response.data['pk']
 | 
			
		||||
        order = models.SalesOrder.objects.get(pk=order_id)
 | 
			
		||||
 | 
			
		||||
        so_line_url = reverse('api-so-line-list')
 | 
			
		||||
 | 
			
		||||
        # Initially, there should be no line items against this order
 | 
			
		||||
        response = self.get(so_line_url, {'order': order_id})
 | 
			
		||||
 | 
			
		||||
        self.assertEqual(len(response.data), 0)
 | 
			
		||||
 | 
			
		||||
        parts = [25, 50, 100]
 | 
			
		||||
 | 
			
		||||
        # Let's create some new line items
 | 
			
		||||
        for part_id in parts:
 | 
			
		||||
            self.post(so_line_url, {'order': order_id, 'part': part_id, 'quantity': 10})
 | 
			
		||||
 | 
			
		||||
        # Should be three items now
 | 
			
		||||
        response = self.get(so_line_url, {'order': order_id})
 | 
			
		||||
 | 
			
		||||
        self.assertEqual(len(response.data), 3)
 | 
			
		||||
 | 
			
		||||
        for item in response.data:
 | 
			
		||||
            # Check that the line item has been created
 | 
			
		||||
            self.assertEqual(item['order'], order_id)
 | 
			
		||||
 | 
			
		||||
            # Check that the line quantities are correct
 | 
			
		||||
            self.assertEqual(item['quantity'], 10)
 | 
			
		||||
            self.assertEqual(item['allocated'], 0)
 | 
			
		||||
            self.assertEqual(item['shipped'], 0)
 | 
			
		||||
 | 
			
		||||
        # Initial API filters should return no results
 | 
			
		||||
        self.filter({'order': order_id, 'allocated': 1}, 0)
 | 
			
		||||
        self.filter({'order': order_id, 'completed': 1}, 0)
 | 
			
		||||
 | 
			
		||||
        # Create a new shipment against this SalesOrder
 | 
			
		||||
        shipment = models.SalesOrderShipment.objects.create(
 | 
			
		||||
            order=order, reference='SHIP-12345'
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Next, allocate stock against 2 line items
 | 
			
		||||
        for item in parts[:2]:
 | 
			
		||||
            p = Part.objects.get(pk=item)
 | 
			
		||||
            s = StockItem.objects.create(part=p, quantity=100)
 | 
			
		||||
            l = models.SalesOrderLineItem.objects.filter(order=order, part=p).first()
 | 
			
		||||
 | 
			
		||||
            # Allocate against the API
 | 
			
		||||
            self.post(
 | 
			
		||||
                reverse('api-so-allocate', kwargs={'pk': order.pk}),
 | 
			
		||||
                {
 | 
			
		||||
                    'items': [{'line_item': l.pk, 'stock_item': s.pk, 'quantity': 10}],
 | 
			
		||||
                    'shipment': shipment.pk,
 | 
			
		||||
                },
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        # Filter by 'fully allocated' status
 | 
			
		||||
        self.filter({'order': order_id, 'allocated': 1}, 2)
 | 
			
		||||
        self.filter({'order': order_id, 'allocated': 0}, 1)
 | 
			
		||||
 | 
			
		||||
        self.filter({'order': order_id, 'completed': 1}, 0)
 | 
			
		||||
        self.filter({'order': order_id, 'completed': 0}, 3)
 | 
			
		||||
 | 
			
		||||
        # Finally, mark this shipment as 'shipped'
 | 
			
		||||
        self.post(reverse('api-so-shipment-ship', kwargs={'pk': shipment.pk}), {})
 | 
			
		||||
 | 
			
		||||
        # Filter by 'completed' status
 | 
			
		||||
        self.filter({'order': order_id, 'completed': 1}, 2)
 | 
			
		||||
        self.filter({'order': order_id, 'completed': 0}, 1)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SalesOrderDownloadTest(OrderTest):
 | 
			
		||||
    """Unit tests for downloading SalesOrder data via the API endpoint."""
 | 
			
		||||
 
 | 
			
		||||
@@ -33,6 +33,7 @@ import { apiUrl } from '../../states/ApiState';
 | 
			
		||||
import { useUserState } from '../../states/UserState';
 | 
			
		||||
import { TableColumn } from '../Column';
 | 
			
		||||
import { DateColumn, LinkColumn, PartColumn } from '../ColumnRenderers';
 | 
			
		||||
import { TableFilter } from '../Filter';
 | 
			
		||||
import { InvenTreeTable } from '../InvenTreeTable';
 | 
			
		||||
import {
 | 
			
		||||
  RowAction,
 | 
			
		||||
@@ -161,6 +162,7 @@ export default function SalesOrderLineItemTable({
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        accessor: 'allocated',
 | 
			
		||||
        sortable: true,
 | 
			
		||||
        render: (record: any) => (
 | 
			
		||||
          <ProgressBar
 | 
			
		||||
            progressLabel={true}
 | 
			
		||||
@@ -171,6 +173,7 @@ export default function SalesOrderLineItemTable({
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        accessor: 'shipped',
 | 
			
		||||
        sortable: true,
 | 
			
		||||
        render: (record: any) => (
 | 
			
		||||
          <ProgressBar
 | 
			
		||||
            progressLabel={true}
 | 
			
		||||
@@ -266,6 +269,21 @@ export default function SalesOrderLineItemTable({
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const tableFilters: TableFilter[] = useMemo(() => {
 | 
			
		||||
    return [
 | 
			
		||||
      {
 | 
			
		||||
        name: 'allocated',
 | 
			
		||||
        label: t`Allocated`,
 | 
			
		||||
        description: t`Show lines which are fully allocated`
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        name: 'completed',
 | 
			
		||||
        label: t`Completed`,
 | 
			
		||||
        description: t`Show lines which are completed`
 | 
			
		||||
      }
 | 
			
		||||
    ];
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const tableActions = useMemo(() => {
 | 
			
		||||
    return [
 | 
			
		||||
      <AddItemButton
 | 
			
		||||
@@ -404,6 +422,7 @@ export default function SalesOrderLineItemTable({
 | 
			
		||||
          },
 | 
			
		||||
          rowActions: rowActions,
 | 
			
		||||
          tableActions: tableActions,
 | 
			
		||||
          tableFilters: tableFilters,
 | 
			
		||||
          modelType: ModelType.part,
 | 
			
		||||
          modelField: 'part'
 | 
			
		||||
        }}
 | 
			
		||||
 
 | 
			
		||||
@@ -80,6 +80,9 @@ test('Stock - Serial Numbers', async ({ page }) => {
 | 
			
		||||
  await page.getByLabel('text-field-serial_numbers').fill('200-250');
 | 
			
		||||
  await page.getByLabel('number-field-quantity').fill('10');
 | 
			
		||||
 | 
			
		||||
  // Add delay to account to field debounce
 | 
			
		||||
  await page.waitForTimeout(250);
 | 
			
		||||
 | 
			
		||||
  await page.getByRole('button', { name: 'Submit' }).click();
 | 
			
		||||
 | 
			
		||||
  // Expected error messages
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user