mirror of
https://github.com/inventree/InvenTree.git
synced 2026-07-28 01:17:21 +00:00
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:
@@ -1,11 +1,14 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""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 = """
|
||||||
|
|
||||||
|
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
|
v527 -> 2026-07-24 : https://github.com/inventree/InvenTree/pull/12454
|
||||||
- Extend API with dedicated login-by-code re-send endpoint
|
- Extend API with dedicated login-by-code re-send endpoint
|
||||||
|
|
||||||
|
|||||||
@@ -1087,6 +1087,8 @@ class SalesOrderLineItemList(
|
|||||||
'sale_price',
|
'sale_price',
|
||||||
'target_date',
|
'target_date',
|
||||||
'line',
|
'line',
|
||||||
|
'status',
|
||||||
|
'shipment_date',
|
||||||
]
|
]
|
||||||
|
|
||||||
ordering_field_aliases = {
|
ordering_field_aliases = {
|
||||||
@@ -1095,6 +1097,8 @@ class SalesOrderLineItemList(
|
|||||||
'IPN': 'part__IPN',
|
'IPN': 'part__IPN',
|
||||||
'order': 'order__reference',
|
'order': 'order__reference',
|
||||||
'line': ['line_int', 'line', 'part__name'],
|
'line': ['line_int', 'line', 'part__name'],
|
||||||
|
'status': 'order__status',
|
||||||
|
'shipment_date': 'order__shipment_date',
|
||||||
}
|
}
|
||||||
|
|
||||||
search_fields = ['part__name', 'quantity', 'reference']
|
search_fields = ['part__name', 'quantity', 'reference']
|
||||||
|
|||||||
@@ -2307,6 +2307,60 @@ class SalesOrderLineItemTest(OrderTest):
|
|||||||
self.filter({'allocated': 'true'}, 1)
|
self.filter({'allocated': 'true'}, 1)
|
||||||
self.filter({'allocated': 'false'}, n - 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):
|
def test_so_line_bulk_delete(self):
|
||||||
"""Test that we can bulk delete multiple SalesOrderLineItems via the API."""
|
"""Test that we can bulk delete multiple SalesOrderLineItems via the API."""
|
||||||
n = models.SalesOrderLineItem.objects.count()
|
n = models.SalesOrderLineItem.objects.count()
|
||||||
|
|||||||
@@ -89,12 +89,12 @@ import { UsedInTable } from '../../tables/bom/UsedInTable';
|
|||||||
import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
|
import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
|
||||||
import { ParameterTable } from '../../tables/general/ParameterTable';
|
import { ParameterTable } from '../../tables/general/ParameterTable';
|
||||||
import PartPurchaseOrdersTable from '../../tables/part/PartPurchaseOrdersTable';
|
import PartPurchaseOrdersTable from '../../tables/part/PartPurchaseOrdersTable';
|
||||||
|
import PartSalesOrdersTable from '../../tables/part/PartSalesOrdersTable';
|
||||||
import PartTestResultTable from '../../tables/part/PartTestResultTable';
|
import PartTestResultTable from '../../tables/part/PartTestResultTable';
|
||||||
import PartTestTemplateTable from '../../tables/part/PartTestTemplateTable';
|
import PartTestTemplateTable from '../../tables/part/PartTestTemplateTable';
|
||||||
import { PartVariantTable } from '../../tables/part/PartVariantTable';
|
import { PartVariantTable } from '../../tables/part/PartVariantTable';
|
||||||
import { RelatedPartTable } from '../../tables/part/RelatedPartTable';
|
import { RelatedPartTable } from '../../tables/part/RelatedPartTable';
|
||||||
import { ReturnOrderTable } from '../../tables/sales/ReturnOrderTable';
|
import { ReturnOrderTable } from '../../tables/sales/ReturnOrderTable';
|
||||||
import { SalesOrderTable } from '../../tables/sales/SalesOrderTable';
|
|
||||||
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
||||||
import { TransferOrderTable } from '../../tables/stock/TransferOrderTable';
|
import { TransferOrderTable } from '../../tables/stock/TransferOrderTable';
|
||||||
import PartAllocationPanel from './PartAllocationPanel';
|
import PartAllocationPanel from './PartAllocationPanel';
|
||||||
@@ -398,7 +398,11 @@ export default function PartDetail() {
|
|||||||
label: t`Sales Orders`,
|
label: t`Sales Orders`,
|
||||||
icon: <IconTruckDelivery />,
|
icon: <IconTruckDelivery />,
|
||||||
hidden: !part.salable || !user.hasViewRole(UserRoles.sales_order),
|
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',
|
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
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user