From ca31bb0322520f91294b5458bcc10448da0648a8 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 23 Oct 2024 07:16:26 +1100 Subject: [PATCH] [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 --- .../InvenTree/InvenTree/api_version.py | 5 +- src/backend/InvenTree/order/api.py | 23 ++++- src/backend/InvenTree/order/serializers.py | 14 ++- src/backend/InvenTree/order/test_api.py | 88 ++++++++++++++++++- .../tables/sales/SalesOrderLineItemTable.tsx | 19 ++++ src/frontend/tests/pages/pui_stock.spec.ts | 3 + 6 files changed, 146 insertions(+), 6 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 946c2c7c8e..eaecb8b673 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -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) diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index c32282c37b..5e3c05d09b 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -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', diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index ac36216aba..ed979ac323 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -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) diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index f635ac8622..aba949e7cb 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -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.""" diff --git a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx index 8c8fb11983..01864cef65 100644 --- a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx +++ b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx @@ -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) => ( ( { + 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 [ { 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