diff --git a/CHANGELOG.md b/CHANGELOG.md index 37e53077a6..7ae546cd20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - +- [#12391](https://github.com/inventree/InvenTree/pull/12391) adds facility for bulk deleting line items against orders - [#12388](https://github.com/inventree/InvenTree/pull/12388) adds uniqueness requirements options for the Parameter and ParameterTemplate models. This allows users to specify whether a parameter value should be unique for a given model type, or globally unique across all models. - [#12310](https://github.com/inventree/InvenTree/pull/12310) adds the ability to disassemble (or break apart) assembled stock items into their component parts, based on the Bill of Materials (BOM) associated with the stock item. This allows users to easily break down assembled items into their constituent parts, which can be useful for inventory management and tracking purposes. - [#12117](https://github.com/inventree/InvenTree/pull/12117) adds a "preview" drawer to the InvenTree table component, allowing users to preview the details of a selected row without navigating away from the table view. This feature is optional and can be enabled or disabled via the `PREVIEW_DRAWER_ENABLED` system setting. diff --git a/docs/docs/stock/transfer_order.md b/docs/docs/stock/transfer_order.md index 12381255e5..b5648f4505 100644 --- a/docs/docs/stock/transfer_order.md +++ b/docs/docs/stock/transfer_order.md @@ -148,3 +148,4 @@ The following [global settings](../settings/global.md) are available for transfe {{ globalsetting("TRANSFERORDER_ENABLED") }} {{ globalsetting("TRANSFERORDER_REFERENCE_PATTERN") }} {{ globalsetting("TRANSFERORDER_REQUIRE_RESPONSIBLE") }} +{{ globalsetting("TRANSFERORDER_EDIT_COMPLETED_ORDERS") }} diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index e7134ba512..f4766c4d9c 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,16 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 522 +INVENTREE_API_VERSION = 523 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v523 -> 2026-07-14 : https://github.com/inventree/InvenTree/pull/12391 + - Adds "bulk delete" support for order line item API endpoints (PurchaseOrder / SalesOrder / ReturnOrder / TransferOrder) + - Adds "bulk delete" support for order extra line item API endpoints + - Completed TransferOrder objects are now "locked" (controlled by the new TRANSFERORDER_EDIT_COMPLETED_ORDERS global setting) + v522 -> 2026-07-14 : https://github.com/inventree/InvenTree/pull/12388 - Adds "unique" field to the ParameterTemplate model diff --git a/src/backend/InvenTree/common/setting/system.py b/src/backend/InvenTree/common/setting/system.py index f950129187..8b28f93a5a 100644 --- a/src/backend/InvenTree/common/setting/system.py +++ b/src/backend/InvenTree/common/setting/system.py @@ -951,6 +951,14 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'default': False, 'validator': bool, }, + 'TRANSFERORDER_EDIT_COMPLETED_ORDERS': { + 'name': _('Edit Completed Transfer Orders'), + 'description': _( + 'Allow editing of transfer orders after they have been completed' + ), + 'default': False, + 'validator': bool, + }, 'SALESORDER_BLOCK_INCOMPLETE_ITEM_TESTS': { 'name': _('Block Incomplete Item Tests'), 'description': _( diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index ab7c65d07e..a5e66434cd 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -770,7 +770,7 @@ class PurchaseOrderLineItemDetail( class PurchaseOrderExtraLineList( - GeneralExtraLineList, OutputOptionsMixin, ListCreateAPI + GeneralExtraLineList, OutputOptionsMixin, ListCreateDestroyAPIView ): """API endpoint for accessing a list of PurchaseOrderExtraLine objects.""" @@ -1062,7 +1062,10 @@ class SalesOrderLineItemOutputOptions(OutputConfiguration): class SalesOrderLineItemList( - SalesOrderLineItemMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateAPI + SalesOrderLineItemMixin, + DataExportViewMixin, + OutputOptionsMixin, + ListCreateDestroyAPIView, ): """API endpoint for accessing a list of SalesOrderLineItem objects.""" @@ -1106,7 +1109,9 @@ class SalesOrderLineItemDetail( output_options = SalesOrderLineItemOutputOptions -class SalesOrderExtraLineList(GeneralExtraLineList, OutputOptionsMixin, ListCreateAPI): +class SalesOrderExtraLineList( + GeneralExtraLineList, OutputOptionsMixin, ListCreateDestroyAPIView +): """API endpoint for accessing a list of SalesOrderExtraLine objects.""" queryset = models.SalesOrderExtraLine.objects.all() @@ -1776,7 +1781,10 @@ class ReturnOrderLineItemOutputOptions(OutputConfiguration): class ReturnOrderLineItemList( - ReturnOrderLineItemMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateAPI + ReturnOrderLineItemMixin, + DataExportViewMixin, + OutputOptionsMixin, + ListCreateDestroyAPIView, ): """API endpoint for accessing a list of ReturnOrderLineItemList objects.""" @@ -1819,7 +1827,9 @@ class ReturnOrderLineItemDetail( output_options = ReturnOrderLineItemOutputOptions -class ReturnOrderExtraLineList(GeneralExtraLineList, OutputOptionsMixin, ListCreateAPI): +class ReturnOrderExtraLineList( + GeneralExtraLineList, OutputOptionsMixin, ListCreateDestroyAPIView +): """API endpoint for accessing a list of ReturnOrderExtraLine objects.""" queryset = models.ReturnOrderExtraLine.objects.all() @@ -2314,7 +2324,10 @@ class TransferOrderLineItemOutputOptions(OutputConfiguration): class TransferOrderLineItemList( - TransferOrderLineItemMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateAPI + TransferOrderLineItemMixin, + DataExportViewMixin, + OutputOptionsMixin, + ListCreateDestroyAPIView, ): """API endpoint for accessing a list of TransferOrderLineItem objects.""" diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index 9f8023b212..f1501e0a73 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -3478,7 +3478,7 @@ class TransferOrder(Order): REFERENCE_PATTERN_SETTING = 'TRANSFERORDER_REFERENCE_PATTERN' REQUIRE_RESPONSIBLE_SETTING = 'TRANSFERORDER_REQUIRE_RESPONSIBLE' STATUS_CLASS = TransferOrderStatus - # UNLOCK_SETTING = 'TRANSFERORDER_EDIT_COMPLETED_ORDERS' + UNLOCK_SETTING = 'TRANSFERORDER_EDIT_COMPLETED_ORDERS' class Meta: """Model meta options.""" diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index 1012f41a68..7e39a92939 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -900,16 +900,46 @@ class PurchaseOrderLineItemTest(OrderTest): """Test that we can bulk delete multiple PurchaseOrderLineItems via the API.""" n = models.PurchaseOrderLineItem.objects.count() - self.assignRole('purchase_order.delete') - url = reverse('api-po-line-list') + # Deletion should fail without the correct role + self.delete(url, {'items': [1, 2]}, expected_code=403) + + self.assignRole('purchase_order.delete') + # Try to delete a set of line items via their IDs self.delete(url, {'items': [1, 2]}, expected_code=200) # We should have 2 less PurchaseOrderLineItems after deleting them self.assertEqual(models.PurchaseOrderLineItem.objects.count(), n - 2) + def test_po_extra_line_bulk_delete(self): + """Test that we can bulk delete multiple PurchaseOrderExtraLine items via the API.""" + po = models.PurchaseOrder.objects.get(pk=1) + + models.PurchaseOrderExtraLine.objects.bulk_create([ + models.PurchaseOrderExtraLine( + order=po, quantity=idx + 1, reference=f'Extra line {idx}' + ) + for idx in range(3) + ]) + + n = models.PurchaseOrderExtraLine.objects.count() + items = list( + models.PurchaseOrderExtraLine.objects.values_list('pk', flat=True)[:2] + ) + + url = reverse('api-po-extra-line-list') + + # Deletion should fail without the correct role + self.delete(url, {'items': items}, expected_code=403) + + self.assignRole('purchase_order.delete') + + self.delete(url, {'items': items}, expected_code=200) + + self.assertEqual(models.PurchaseOrderExtraLine.objects.count(), n - 2) + def test_po_line_merge_pricing(self): """Test that we can create a new PurchaseOrderLineItem via the API.""" self.assignRole('purchase_order.add') @@ -2245,6 +2275,49 @@ class SalesOrderLineItemTest(OrderTest): self.filter({'allocated': 'true'}, 1) self.filter({'allocated': 'false'}, n - 1) + def test_so_line_bulk_delete(self): + """Test that we can bulk delete multiple SalesOrderLineItems via the API.""" + n = models.SalesOrderLineItem.objects.count() + + items = list(models.SalesOrderLineItem.objects.values_list('pk', flat=True)[:2]) + + # Deletion should fail without the correct role + self.delete(self.url, {'items': items}, expected_code=403) + + self.assignRole('sales_order.delete') + + self.delete(self.url, {'items': items}, expected_code=200) + + # We should have 2 less SalesOrderLineItems after deleting them + self.assertEqual(models.SalesOrderLineItem.objects.count(), n - 2) + + def test_so_extra_line_bulk_delete(self): + """Test that we can bulk delete multiple SalesOrderExtraLine items via the API.""" + so = models.SalesOrder.objects.first() + + models.SalesOrderExtraLine.objects.bulk_create([ + models.SalesOrderExtraLine( + order=so, quantity=idx + 1, reference=f'Extra line {idx}' + ) + for idx in range(3) + ]) + + n = models.SalesOrderExtraLine.objects.count() + items = list( + models.SalesOrderExtraLine.objects.values_list('pk', flat=True)[:2] + ) + + url = reverse('api-so-extra-line-list') + + # Deletion should fail without the correct role + self.delete(url, {'items': items}, expected_code=403) + + self.assignRole('sales_order.delete') + + self.delete(url, {'items': items}, expected_code=200) + + self.assertEqual(models.SalesOrderExtraLine.objects.count(), n - 2) + def test_so_line_allocated_filters(self): """Test filtering by allocation status for a SalesOrderLineItem.""" self.assignRole('sales_order.add') @@ -3458,6 +3531,53 @@ class ReturnOrderLineItemTests(InvenTreeAPITestCase): line = models.ReturnOrderLineItem.objects.get(pk=1) self.assertEqual(float(line.price.amount), 15.75) + def test_bulk_delete(self): + """Test that we can bulk delete multiple ReturnOrderLineItems via the API.""" + n = models.ReturnOrderLineItem.objects.count() + self.assertGreater(n, 0) + + items = list( + models.ReturnOrderLineItem.objects.values_list('pk', flat=True)[:1] + ) + + url = reverse('api-return-order-line-list') + + # Deletion should fail without the correct role + self.delete(url, {'items': items}, expected_code=403) + + self.assignRole('return_order.delete') + + self.delete(url, {'items': items}, expected_code=200) + + self.assertEqual(models.ReturnOrderLineItem.objects.count(), n - 1) + + def test_extra_line_bulk_delete(self): + """Test that we can bulk delete multiple ReturnOrderExtraLine items via the API.""" + ro = models.ReturnOrder.objects.first() + + models.ReturnOrderExtraLine.objects.bulk_create([ + models.ReturnOrderExtraLine( + order=ro, quantity=idx + 1, reference=f'Extra line {idx}' + ) + for idx in range(3) + ]) + + n = models.ReturnOrderExtraLine.objects.count() + items = list( + models.ReturnOrderExtraLine.objects.values_list('pk', flat=True)[:2] + ) + + url = reverse('api-return-order-extra-line-list') + + # Deletion should fail without the correct role + self.delete(url, {'items': items}, expected_code=403) + + self.assignRole('return_order.delete') + + self.delete(url, {'items': items}, expected_code=200) + + self.assertEqual(models.ReturnOrderExtraLine.objects.count(), n - 2) + class TransferOrderTest(OrderTest): """Tests for the TransferOrder API.""" @@ -4098,6 +4218,63 @@ class TransferOrderLineItemTest(OrderTest): self.filter({'allocated': 'true'}, 2) self.filter({'allocated': 'false'}, n - 2) + def test_transfer_order_line_bulk_delete(self): + """Test that we can bulk delete multiple TransferOrderLineItems via the API.""" + n = models.TransferOrderLineItem.objects.count() + + # Select lines from orders which are not completed (and thus not locked) + items = list( + models.TransferOrderLineItem.objects.exclude( + order__status__in=TransferOrderStatusGroups.COMPLETE + ).values_list('pk', flat=True)[:2] + ) + + # Deletion should fail without the correct role + self.delete(self.url, {'items': items}, expected_code=403) + + self.assignRole('transfer_order.delete') + + self.delete(self.url, {'items': items}, expected_code=200) + + # We should have 2 less TransferOrderLineItems after deleting them + self.assertEqual(models.TransferOrderLineItem.objects.count(), n - 2) + + def test_completed_order_locked(self): + """Test that line items cannot be deleted from a completed TransferOrder.""" + self.assignRole('transfer_order.delete') + + set_global_setting(models.TransferOrder.UNLOCK_SETTING, False) + + order = models.TransferOrder.objects.filter( + status=TransferOrderStatus.PENDING.value, lines__isnull=False + ).first() + assert order + + # Mark the order as complete + order.status = TransferOrderStatus.COMPLETE.value + order.save() + + n = order.lines.count() + self.assertGreater(n, 1) + + line = order.lines.first() + detail_url = reverse('api-transfer-order-line-detail', kwargs={'pk': line.pk}) + + # Single deletion of a line item should fail + self.delete(detail_url, expected_code=400) + + # Bulk deletion should also fail (and roll back atomically) + items = list(order.lines.values_list('pk', flat=True)) + self.delete(self.url, {'items': items}, expected_code=400) + + self.assertEqual(order.lines.count(), n) + + # Unlocking completed orders should allow deletion again + set_global_setting(models.TransferOrder.UNLOCK_SETTING, True) + + self.delete(detail_url, expected_code=204) + self.assertEqual(order.lines.count(), n - 1) + def test_transfer_order_line_allocated_filters(self): """Test filtering by allocation status for a TransferOrderLineItem.""" self.assignRole('transfer_order.add') diff --git a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx index f342daccb5..36a21214c5 100644 --- a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx +++ b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx @@ -390,7 +390,8 @@ export default function SystemSettings() { keys={[ 'TRANSFERORDER_ENABLED', 'TRANSFERORDER_REFERENCE_PATTERN', - 'TRANSFERORDER_REQUIRE_RESPONSIBLE' + 'TRANSFERORDER_REQUIRE_RESPONSIBLE', + 'TRANSFERORDER_EDIT_COMPLETED_ORDERS' ]} /> ) diff --git a/src/frontend/src/pages/stock/TransferOrderDetail.tsx b/src/frontend/src/pages/stock/TransferOrderDetail.tsx index edc9dba94d..3863cee466 100644 --- a/src/frontend/src/pages/stock/TransferOrderDetail.tsx +++ b/src/frontend/src/pages/stock/TransferOrderDetail.tsx @@ -69,13 +69,11 @@ export default function TransferOrderDetail() { const orderOpen: boolean = order.status != toStatus.COMPLETE && order.status != toStatus.CANCELLED; - return orderOpen; - // TODO: does this setting make any sense for Transfer Orders??? - // if (orderOpen) { - // return true; - // } else { - // return globalSettings.isSet('TRANSFERORDER_EDIT_COMPLETED_ORDERS'); - // } + if (orderOpen) { + return true; + } else { + return globalSettings.isSet('TRANSFERORDER_EDIT_COMPLETED_ORDERS'); + } }, [globalSettings, order.status, toStatus]); // for now, only permit editing allocations when line items can be edited diff --git a/src/frontend/src/tables/general/ExtraLineItemTable.tsx b/src/frontend/src/tables/general/ExtraLineItemTable.tsx index f675b00aa2..43b5b25d72 100644 --- a/src/frontend/src/tables/general/ExtraLineItemTable.tsx +++ b/src/frontend/src/tables/general/ExtraLineItemTable.tsx @@ -179,6 +179,9 @@ export default function ExtraLineItemTable({ params: { order: orderId }, + enableSelection: true, + enableBulkDelete: editable && user.hasDeleteRole(role), + afterBulkDelete: orderDetailRefresh, defaultSortColumn: 'line', rowActions: rowActions, tableActions: tableActions diff --git a/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx b/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx index 773ca49af2..335605c473 100644 --- a/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx +++ b/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx @@ -435,6 +435,9 @@ export function PurchaseOrderLineItemTable({ props={{ enableSelection: true, enableDownload: true, + enableBulkDelete: + editable && user.hasDeleteRole(UserRoles.purchase_order), + afterBulkDelete: orderDetailRefresh, defaultSortColumn: 'line', params: { ...params, diff --git a/src/frontend/src/tables/sales/ReturnOrderLineItemTable.tsx b/src/frontend/src/tables/sales/ReturnOrderLineItemTable.tsx index 18cbdeb7af..fc847ae775 100644 --- a/src/frontend/src/tables/sales/ReturnOrderLineItemTable.tsx +++ b/src/frontend/src/tables/sales/ReturnOrderLineItemTable.tsx @@ -274,6 +274,9 @@ export default function ReturnOrderLineItemTable({ defaultSortColumn: 'line', enableSelection: inProgress && user.hasChangeRole(UserRoles.return_order), + enableBulkDelete: + editable && user.hasDeleteRole(UserRoles.return_order), + afterBulkDelete: orderDetailRefresh, tableActions: tableActions, tableFilters: tableFilters, rowActions: rowActions, diff --git a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx index 9843da3f58..6ebf3e89b5 100644 --- a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx +++ b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx @@ -609,6 +609,9 @@ export default function SalesOrderLineItemTable({ props={{ enableSelection: true, enableDownload: true, + enableBulkDelete: + editable && user.hasDeleteRole(UserRoles.sales_order), + afterBulkDelete: orderDetailRefresh, params: { order: orderId, part_detail: true diff --git a/src/frontend/src/tables/stock/TransferOrderLineItemTable.tsx b/src/frontend/src/tables/stock/TransferOrderLineItemTable.tsx index fb1534b6ea..64d13900e6 100644 --- a/src/frontend/src/tables/stock/TransferOrderLineItemTable.tsx +++ b/src/frontend/src/tables/stock/TransferOrderLineItemTable.tsx @@ -517,6 +517,9 @@ export default function TransferOrderLineItemTable({ props={{ enableSelection: true, enableDownload: true, + enableBulkDelete: + editable && user.hasDeleteRole(UserRoles.transfer_order), + afterBulkDelete: orderDetailRefresh, params: { order: orderId, part_detail: true diff --git a/src/frontend/tests/pages/pui_build.spec.ts b/src/frontend/tests/pages/pui_build.spec.ts index 87f23049fd..1ba14e45b6 100644 --- a/src/frontend/tests/pages/pui_build.spec.ts +++ b/src/frontend/tests/pages/pui_build.spec.ts @@ -283,7 +283,7 @@ test('Build Order - Build Outputs', async ({ browser }) => { await page.waitForLoadState('networkidle'); // Check the "printing" actions for the selected outputs - await page.getByRole('checkbox', { name: 'Select all records' }).check(); + await page.getByRole('checkbox', { name: 'Select all records' }).click(); await page .getByRole('tabpanel', { name: 'Incomplete Outputs' }) .getByLabel('action-menu-printing-actions') diff --git a/src/frontend/tests/pages/pui_purchasing.spec.ts b/src/frontend/tests/pages/pui_purchasing.spec.ts index 5ea23cb541..378d1192e8 100644 --- a/src/frontend/tests/pages/pui_purchasing.spec.ts +++ b/src/frontend/tests/pages/pui_purchasing.spec.ts @@ -507,7 +507,12 @@ test('Purchase Orders - Receive Items', async ({ browser }) => { await loadTab(page, 'Line Items'); await page.getByRole('cell', { name: '002.02-PCB' }).waitFor(); - await page.getByLabel('Select all records').click(); + + // Select all line items to receive + await page + .getByRole('region', { name: 'Line Items', exact: true }) + .getByLabel('Select all records') + .click(); await page.waitForTimeout(100); await page.getByLabel('action-button-receive-items').click(); @@ -619,7 +624,10 @@ test('Purchase Orders - Receive Virtual Items', async ({ browser }) => { await loadTab(page, 'Line Items'); await page.getByRole('cell', { name: 'Thumbnail CRM license' }).waitFor(); - await page.getByRole('checkbox', { name: 'Select all records' }).click(); + await page + .getByRole('region', { name: 'Line Items', exact: true }) + .getByLabel('Select all records') + .click(); await page .getByRole('button', { name: 'action-button-receive-items' }) .click(); diff --git a/src/frontend/tests/pages/pui_return.spec.ts b/src/frontend/tests/pages/pui_return.spec.ts index 5ffd09aada..d4c9e01133 100644 --- a/src/frontend/tests/pages/pui_return.spec.ts +++ b/src/frontend/tests/pages/pui_return.spec.ts @@ -14,7 +14,10 @@ test('Return Orders - Receive Items', async ({ browser }) => { await page.getByRole('cell', { name: 'WID-REV-A' }).first().waitFor(); - await page.getByRole('checkbox', { name: 'Select all records' }).click(); + await page + .getByRole('region', { name: 'Line Items', exact: true }) + .getByLabel('Select all records') + .click(); await page.getByRole('button', { name: 'action-button-receive-' }).click(); await page.getByRole('banner').getByText('Receive Items').waitFor(); await page.getByRole('button', { name: 'Cancel' }).click();