2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 11:36:44 +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:
Oliver 2024-10-23 07:16:26 +11:00 committed by GitHub
parent 2adb41f448
commit ca31bb0322
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 146 additions and 6 deletions

View File

@ -1,13 +1,16 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # 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.""" """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 = """
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 v270 - 2024-10-19 : https://github.com/inventree/InvenTree/pull/8307
- Adds missing date fields from order API endpoint(s) - Adds missing date fields from order API endpoint(s)

View File

@ -771,12 +771,29 @@ class SalesOrderLineItemFilter(LineItemFilter):
queryset=Part.objects.all(), field_name='part', label=_('Part') 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): def filter_completed(self, queryset, name, value):
"""Filter by lines which are "completed". """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')) q = Q(shipped__gte=F('quantity'))
@ -855,6 +872,8 @@ class SalesOrderLineItemList(
'part', 'part',
'part__name', 'part__name',
'quantity', 'quantity',
'allocated',
'shipped',
'reference', 'reference',
'sale_price', 'sale_price',
'target_date', 'target_date',

View File

@ -14,11 +14,12 @@ from django.db.models import (
Value, Value,
When, When,
) )
from django.db.models.functions import Coalesce
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
from sql_util.utils import SubqueryCount from sql_util.utils import SubqueryCount, SubquerySum
import order.models import order.models
import part.filters as part_filters import part.filters as part_filters
@ -1165,6 +1166,15 @@ class SalesOrderLineItemSerializer(
building=part_filters.annotate_in_production_quantity(reference='part__') 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 return queryset
order_detail = SalesOrderSerializer(source='order', many=False, read_only=True) order_detail = SalesOrderSerializer(source='order', many=False, read_only=True)
@ -1182,7 +1192,7 @@ class SalesOrderLineItemSerializer(
quantity = InvenTreeDecimalField() quantity = InvenTreeDecimalField()
allocated = serializers.FloatField(source='allocated_quantity', read_only=True) allocated = serializers.FloatField(read_only=True)
shipped = InvenTreeDecimalField(read_only=True) shipped = InvenTreeDecimalField(read_only=True)

View File

@ -1683,10 +1683,96 @@ class SalesOrderLineItemTest(OrderTest):
self.filter({'has_pricing': 1}, 0) self.filter({'has_pricing': 1}, 0)
self.filter({'has_pricing': 0}, n) self.filter({'has_pricing': 0}, n)
# Filter by has_pricing status # Filter by 'completed' status
self.filter({'completed': 1}, 0) self.filter({'completed': 1}, 0)
self.filter({'completed': 0}, n) 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): class SalesOrderDownloadTest(OrderTest):
"""Unit tests for downloading SalesOrder data via the API endpoint.""" """Unit tests for downloading SalesOrder data via the API endpoint."""

View File

@ -33,6 +33,7 @@ import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
import { TableColumn } from '../Column'; import { TableColumn } from '../Column';
import { DateColumn, LinkColumn, PartColumn } from '../ColumnRenderers'; import { DateColumn, LinkColumn, PartColumn } from '../ColumnRenderers';
import { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable'; import { InvenTreeTable } from '../InvenTreeTable';
import { import {
RowAction, RowAction,
@ -161,6 +162,7 @@ export default function SalesOrderLineItemTable({
}, },
{ {
accessor: 'allocated', accessor: 'allocated',
sortable: true,
render: (record: any) => ( render: (record: any) => (
<ProgressBar <ProgressBar
progressLabel={true} progressLabel={true}
@ -171,6 +173,7 @@ export default function SalesOrderLineItemTable({
}, },
{ {
accessor: 'shipped', accessor: 'shipped',
sortable: true,
render: (record: any) => ( render: (record: any) => (
<ProgressBar <ProgressBar
progressLabel={true} 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(() => { const tableActions = useMemo(() => {
return [ return [
<AddItemButton <AddItemButton
@ -404,6 +422,7 @@ export default function SalesOrderLineItemTable({
}, },
rowActions: rowActions, rowActions: rowActions,
tableActions: tableActions, tableActions: tableActions,
tableFilters: tableFilters,
modelType: ModelType.part, modelType: ModelType.part,
modelField: 'part' modelField: 'part'
}} }}

View File

@ -80,6 +80,9 @@ test('Stock - Serial Numbers', async ({ page }) => {
await page.getByLabel('text-field-serial_numbers').fill('200-250'); await page.getByLabel('text-field-serial_numbers').fill('200-250');
await page.getByLabel('number-field-quantity').fill('10'); 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(); await page.getByRole('button', { name: 'Submit' }).click();
// Expected error messages // Expected error messages