Fix part SalesOrder table (#12469)

* Fix part SalesOrder table

- Show line item quantity, not number of lines

* add regression test

* Bump API version
This commit is contained in:
Oliver
2026-07-26 21:38:41 +10:00
committed by GitHub
parent 1bc758624e
commit b7d65d754b
5 changed files with 197 additions and 3 deletions
@@ -1,11 +1,14 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 527
INVENTREE_API_VERSION = 528
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v528 -> 2026-07-26 : https://github.com/inventree/InvenTree/pull/12469
- Additional ordering options for SalesOrderAllocation API endpoint
v527 -> 2026-07-24 : https://github.com/inventree/InvenTree/pull/12454
- Extend API with dedicated login-by-code re-send endpoint
+4
View File
@@ -1087,6 +1087,8 @@ class SalesOrderLineItemList(
'sale_price',
'target_date',
'line',
'status',
'shipment_date',
]
ordering_field_aliases = {
@@ -1095,6 +1097,8 @@ class SalesOrderLineItemList(
'IPN': 'part__IPN',
'order': 'order__reference',
'line': ['line_int', 'line', 'part__name'],
'status': 'order__status',
'shipment_date': 'order__shipment_date',
}
search_fields = ['part__name', 'quantity', 'reference']
+54
View File
@@ -2307,6 +2307,60 @@ class SalesOrderLineItemTest(OrderTest):
self.filter({'allocated': 'true'}, 1)
self.filter({'allocated': 'false'}, n - 1)
def test_so_line_ordering(self):
"""Test that the SalesOrderLineItem list can be ordered by order-related fields.
Regression test for aliased ordering fields ('status', 'shipment_date')
which are not present directly on the SalesOrderLineItem model,
but rather on the linked SalesOrder.
"""
# Orders 1-3 are 'pending' (status=10), order 4 is 'shipped' (status=20),
# order 5 is 'returned' (status=60), order 6 is 'complete' (status=30)
# (refer to the 'sales_order' fixture data)
# Order by 'status' (aliased to 'order__status')
response = self.get(
self.url, {'ordering': 'status', 'order_detail': True}, expected_code=200
)
statuses = [item['order_detail']['status'] for item in response.data]
self.assertEqual(statuses, sorted(statuses))
self.assertEqual(statuses[0], SalesOrderStatus.PENDING.value)
self.assertEqual(statuses[-1], SalesOrderStatus.RETURNED.value)
# Reverse the ordering
response = self.get(
self.url, {'ordering': '-status', 'order_detail': True}, expected_code=200
)
statuses = [item['order_detail']['status'] for item in response.data]
self.assertEqual(statuses, sorted(statuses, reverse=True))
self.assertEqual(statuses[0], SalesOrderStatus.RETURNED.value)
# Order by 'shipment_date' (aliased to 'order__shipment_date')
order_a = models.SalesOrder.objects.get(pk=4)
order_b = models.SalesOrder.objects.get(pk=5)
order_a.shipment_date = date(2020, 1, 1)
order_a.save()
order_b.shipment_date = date(2024, 1, 1)
order_b.save()
response = self.get(self.url, {'ordering': 'shipment_date'}, expected_code=200)
order_ids = [item['order'] for item in response.data]
# Lines for 'order_a' (earlier shipment date) should sort before 'order_b'
self.assertLess(order_ids.index(order_a.pk), order_ids.index(order_b.pk))
# Reverse the ordering - 'order_b' should now come first
response = self.get(self.url, {'ordering': '-shipment_date'}, expected_code=200)
order_ids = [item['order'] for item in response.data]
self.assertLess(order_ids.index(order_b.pk), order_ids.index(order_a.pk))
def test_so_line_bulk_delete(self):
"""Test that we can bulk delete multiple SalesOrderLineItems via the API."""
n = models.SalesOrderLineItem.objects.count()
+6 -2
View File
@@ -89,12 +89,12 @@ import { UsedInTable } from '../../tables/bom/UsedInTable';
import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
import { ParameterTable } from '../../tables/general/ParameterTable';
import PartPurchaseOrdersTable from '../../tables/part/PartPurchaseOrdersTable';
import PartSalesOrdersTable from '../../tables/part/PartSalesOrdersTable';
import PartTestResultTable from '../../tables/part/PartTestResultTable';
import PartTestTemplateTable from '../../tables/part/PartTestTemplateTable';
import { PartVariantTable } from '../../tables/part/PartVariantTable';
import { RelatedPartTable } from '../../tables/part/RelatedPartTable';
import { ReturnOrderTable } from '../../tables/sales/ReturnOrderTable';
import { SalesOrderTable } from '../../tables/sales/SalesOrderTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
import { TransferOrderTable } from '../../tables/stock/TransferOrderTable';
import PartAllocationPanel from './PartAllocationPanel';
@@ -398,7 +398,11 @@ export default function PartDetail() {
label: t`Sales Orders`,
icon: <IconTruckDelivery />,
hidden: !part.salable || !user.hasViewRole(UserRoles.sales_order),
content: part.pk ? <SalesOrderTable partId={part.pk} /> : <Skeleton />
content: part.pk ? (
<PartSalesOrdersTable partId={part.pk} />
) : (
<Skeleton />
)
},
{
name: 'return_orders',
@@ -0,0 +1,129 @@
import { t } from '@lingui/core/macro';
import { useMemo } from 'react';
import { ProgressBar } from '@lib/components/ProgressBar';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api';
import useTable from '@lib/hooks/UseTable';
import type { TableFilter } from '@lib/types/Filters';
import type { TableColumn } from '@lib/types/Tables';
import {
CompanyColumn,
DateColumn,
ReferenceColumn,
StatusColumn
} from '../../components/tables/ColumnRenderers';
import {
IncludeVariantsFilter,
StatusFilterOptions
} from '../../components/tables/Filter';
import { InvenTreeTable } from '../../components/tables/InvenTreeTable';
import { formatCurrency } from '../../defaults/formatters';
export default function PartSalesOrdersTable({
partId
}: Readonly<{
partId: number;
}>) {
const table = useTable('partsalesorders');
const tableColumns: TableColumn[] = useMemo(() => {
return [
ReferenceColumn({
accessor: 'order_detail.reference',
ordering: 'order',
sortable: true,
switchable: false,
filter: ['order_outstanding', 'completed'],
title: t`Sales Order`
}),
StatusColumn({
accessor: 'order_detail.status',
sortable: true,
ordering: 'status',
title: t`Status`,
filter: 'order_status',
model: ModelType.salesorder
}),
{
accessor: 'customer_detail.name',
title: t`Customer`,
sortable: false,
switchable: true,
render: (record: any) => (
<CompanyColumn company={record.customer_detail} />
)
},
{
accessor: 'quantity',
sortable: true,
switchable: false,
render: (record: any) => (
<ProgressBar
progressLabel
value={record.shipped}
maximum={record.quantity}
/>
)
},
DateColumn({
accessor: 'target_date',
title: t`Target Date`
}),
DateColumn({
accessor: 'order_detail.shipment_date',
ordering: 'shipment_date',
title: t`Shipment Date`
}),
{
accessor: 'sale_price',
render: (record: any) =>
formatCurrency(record.sale_price, {
currency: record.sale_price_currency
})
}
];
}, []);
const tableFilters: TableFilter[] = useMemo(() => {
return [
{
name: 'order_outstanding',
label: t`Outstanding`,
description: t`Show outstanding orders`
},
{
name: 'completed',
label: t`Completed`,
description: t`Show completed line items`
},
{
name: 'order_status',
label: t`Order Status`,
description: t`Filter by order status`,
choiceFunction: StatusFilterOptions(ModelType.salesorder)
},
IncludeVariantsFilter()
];
}, []);
return (
<InvenTreeTable
url={apiUrl(ApiEndpoints.sales_order_line_list)}
tableState={table}
columns={tableColumns}
props={{
params: {
part: partId,
part_detail: true,
order_detail: true,
customer_detail: true
},
modelField: 'order',
modelType: ModelType.salesorder,
tableFilters: tableFilters
}}
/>
);
}