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:
parent
2adb41f448
commit
ca31bb0322
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user