diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 1f6786d296..09940c1bcc 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index f8874c1cea..057838c8ed 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -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'] diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index 1203684ae1..28d42ea984 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -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() diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 6fe4ea4de7..2bfcf93db9 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -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: , hidden: !part.salable || !user.hasViewRole(UserRoles.sales_order), - content: part.pk ? : + content: part.pk ? ( + + ) : ( + + ) }, { name: 'return_orders', diff --git a/src/frontend/src/tables/part/PartSalesOrdersTable.tsx b/src/frontend/src/tables/part/PartSalesOrdersTable.tsx new file mode 100644 index 0000000000..d8e8661e71 --- /dev/null +++ b/src/frontend/src/tables/part/PartSalesOrdersTable.tsx @@ -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) => ( + + ) + }, + { + accessor: 'quantity', + sortable: true, + switchable: false, + render: (record: any) => ( + + ) + }, + 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 ( + + ); +}