Bulk delete line items (#12391)

* Add bulk-delete mixin for order line items

* Add unit tests

* Enable frontend support

* Add CHANGELOG entry

* Add locking control for TransferOrder

* Fix playwright tests

* Fix playwright tests
This commit is contained in:
Oliver 2026-07-16 11:55:34 +10:00 committed by GitHub
parent 8407f63656
commit 3fd0e9d304
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 252 additions and 23 deletions

View File

@ -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.

View File

@ -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") }}

View File

@ -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

View File

@ -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': _(

View File

@ -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."""

View File

@ -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."""

View File

@ -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')

View File

@ -390,7 +390,8 @@ export default function SystemSettings() {
keys={[
'TRANSFERORDER_ENABLED',
'TRANSFERORDER_REFERENCE_PATTERN',
'TRANSFERORDER_REQUIRE_RESPONSIBLE'
'TRANSFERORDER_REQUIRE_RESPONSIBLE',
'TRANSFERORDER_EDIT_COMPLETED_ORDERS'
]}
/>
)

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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')

View File

@ -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();

View File

@ -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();