mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 19:46:46 +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 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)
|
||||||
|
|
||||||
|
@ -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',
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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."""
|
||||||
|
@ -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'
|
||||||
}}
|
}}
|
||||||
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user