From 38008d8204579ff58c0b0264488b2d4d6850195a Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 17 Jun 2026 14:50:17 +1000 Subject: [PATCH] Mysql filter fix (#12185) * Improve "available" filter for BuildLine API endpoint * Fix typo * Additional unit tests * Additional playwright tests --- src/backend/InvenTree/build/api.py | 26 +++- src/backend/InvenTree/build/models.py | 2 +- src/backend/InvenTree/build/test_api.py | 171 ++++++++++++++++++++- src/frontend/tests/pages/pui_build.spec.ts | 12 ++ 4 files changed, 204 insertions(+), 7 deletions(-) diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index 571f2b1e7c..8b46d7ca5c 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -3,7 +3,8 @@ from __future__ import annotations from django.contrib.auth.models import User -from django.db.models import F, Q +from django.db.models import F, OuterRef, Q, Subquery, Sum +from django.db.models.functions import Coalesce from django.urls import include, path from django.utils.translation import gettext_lazy as _ @@ -520,8 +521,22 @@ class BuildLineFilter(FilterSet): - The quantity available for each BuildLine (including variants and substitutes) - The quantity allocated for each BuildLine """ - flt = Q( - quantity__lte=F('allocated') + allocated_subquery = ( + BuildItem.objects + .filter(build_line=OuterRef('pk')) + .values('build_line') + .annotate(total=Sum('quantity')) + .values('total') + ) + + queryset = queryset.alias( + allocated_quantity=Coalesce(Subquery(allocated_subquery), 0) + ) + + # A query filter construct to determine the total quantity available for this BuildLine, + # taking into account any stock which is already allocated or consumed + available = ( + F('allocated_quantity') + F('consumed') + F('available_stock') + F('available_substitute_stock') @@ -529,8 +544,9 @@ class BuildLineFilter(FilterSet): ) if str2bool(value): - return queryset.filter(flt) - return queryset.exclude(flt) + return queryset.filter(quantity__lte=available) + + return queryset.filter(quantity__gt=available) on_order = rest_filters.BooleanFilter(label=_('On Order'), method='filter_on_order') diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index 09eb77e3a5..e2167e3532 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -965,7 +965,7 @@ class Build( # Remove the build output from the database # This is a special case where serialized stock can be deleted, - # independedent of the global setting which normally prevents deletion of serialized stock items + # independent of the global setting which normally prevents deletion of serialized stock items output.delete(ignore_serial_check=True) @transaction.atomic diff --git a/src/backend/InvenTree/build/test_api.py b/src/backend/InvenTree/build/test_api.py index 6050774e80..0833aeb45d 100644 --- a/src/backend/InvenTree/build/test_api.py +++ b/src/backend/InvenTree/build/test_api.py @@ -11,7 +11,7 @@ from build.models import Build, BuildItem, BuildLine from build.status_codes import BuildStatus from common.settings import set_global_setting from InvenTree.unit_test import InvenTreeAPITestCase -from part.models import BomItem, Part +from part.models import BomItem, BomItemSubstitute, Part from stock.models import StockItem, StockLocation, StockSortOrder from stock.status_codes import StockStatus @@ -1625,6 +1625,175 @@ class BuildLineTests(BuildAPITest): self.assertEqual(n_t + n_f, BuildLine.objects.count()) + def test_filter_available_allocated_consumed_mixed(self): + """Filter BuildLine objects with mixed allocated / consumed / stock states.""" + assembly = Part.objects.create( + name='Available Filter Assembly', + description='Assembly for advanced available filter tests', + assembly=True, + ) + + components = [ + Part.objects.create( + name=f'Available Filter Component {idx}', + description=f'Component {idx}', + component=True, + ) + for idx in range(4) + ] + + # The assembly uses 10x of each component + for component in components: + BomItem.objects.create(part=assembly, sub_part=component, quantity=10) + + # Create a new Build, requiring 100x of each component + build = Build.objects.create( + part=assembly, + reference='BO-9999', + quantity=10, + title='Available Filter Mixed', + ) + + lines = list(build.build_lines.order_by('pk')) + self.assertEqual(len(lines), 4) + + # Build line quantity is 100 for each line (10 * build quantity 10) + for line in lines: + self.assertEqual(line.quantity, 100) + + # Stock allocation baseline for each component + # Note: Quantity values will be updated later + stock_items = [ + StockItem.objects.create(part=component, quantity=1) + for component in components + ] + + # Line 0: AVAILABLE = True + # allocated = 30 + # consumed = 0 + # available = 70 + BuildItem.objects.create( + build_line=lines[0], stock_item=stock_items[0], quantity=30 + ) + + stock_items[0].quantity = 30 + 70 + stock_items[0].save() + + # Line 1: allocated 20 + consumed 30 + available stock 50 => available (100) + BuildItem.objects.create( + build_line=lines[1], stock_item=stock_items[1], quantity=20 + ) + lines[1].consumed = 30 + lines[1].save() + stock_items[1].quantity = 20 + 50 + stock_items[1].save() + + # Line 2: allocated 0 + consumed 10 + available stock 50 => not available (60) + lines[2].consumed = 10 + lines[2].save() + stock_items[2].quantity = 50 + stock_items[2].save() + + # Line 3: allocated 40 + consumed 0 + available stock 20 => not available (60) + BuildItem.objects.create( + build_line=lines[3], stock_item=stock_items[3], quantity=40 + ) + stock_items[3].quantity = 40 + 20 + stock_items[3].save() + + url = reverse('api-build-line-list') + + response_true = self.get(url, {'build': build.pk, 'available': True}) + + response_false = self.get(url, {'build': build.pk, 'available': False}) + + true_ids = {item['pk'] for item in response_true.data} + false_ids = {item['pk'] for item in response_false.data} + + self.assertSetEqual(true_ids, {lines[0].pk, lines[1].pk}) + self.assertSetEqual(false_ids, {lines[2].pk, lines[3].pk}) + self.assertSetEqual(true_ids | false_ids, {line.pk for line in lines}) + + def test_filter_available_substitute_and_variant_stock(self): + """Filter BuildLine objects where availability comes from substitute or variant stock.""" + assembly = Part.objects.create( + name='Available Filter Sub/Var Assembly', + description='Assembly for substitute and variant availability tests', + assembly=True, + ) + + # Substitute path: line should pass via substitute stock + sub_master_ok = Part.objects.create(name='Sub Master OK', component=True) + sub_alt_ok = Part.objects.create(name='Sub Alt OK', component=True) + + # Substitute path: line should fail (insufficient substitute stock) + sub_master_low = Part.objects.create(name='Sub Master Low', component=True) + sub_alt_low = Part.objects.create(name='Sub Alt Low', component=True) + + # Variant path: line should pass via variant stock + var_parent_ok = Part.objects.create( + name='Variant Parent OK', component=True, is_template=True + ) + var_child_ok = Part.objects.create( + name='Variant Child OK', component=True, variant_of=var_parent_ok + ) + + # Variant path: line should fail (insufficient variant stock) + var_parent_low = Part.objects.create( + name='Variant Parent Low', component=True, is_template=True + ) + var_child_low = Part.objects.create( + name='Variant Child Low', component=True, variant_of=var_parent_low + ) + + bom_sub_ok = BomItem.objects.create( + part=assembly, sub_part=sub_master_ok, quantity=10, allow_variants=False + ) + bom_sub_low = BomItem.objects.create( + part=assembly, sub_part=sub_master_low, quantity=10, allow_variants=False + ) + bom_var_ok = BomItem.objects.create( + part=assembly, sub_part=var_parent_ok, quantity=10, allow_variants=True + ) + bom_var_low = BomItem.objects.create( + part=assembly, sub_part=var_parent_low, quantity=10, allow_variants=True + ) + + BomItemSubstitute.objects.create(bom_item=bom_sub_ok, part=sub_alt_ok) + BomItemSubstitute.objects.create(bom_item=bom_sub_low, part=sub_alt_low) + + # Build quantity 10 => each line requires 100 units + build = Build.objects.create( + part=assembly, reference='BO-0987', quantity=10, title='Available Sub/Var' + ) + + lines = list(build.build_lines.order_by('pk')) + self.assertEqual(len(lines), 4) + + # Keep master parts at zero stock so only substitute/variant paths contribute + StockItem.objects.create(part=sub_alt_ok, quantity=100) + StockItem.objects.create(part=sub_alt_low, quantity=40) + StockItem.objects.create(part=var_child_ok, quantity=100) + StockItem.objects.create(part=var_child_low, quantity=40) + + url = reverse('api-build-line-list') + + response_true = self.get(url, {'build': build.pk, 'available': True}) + + response_false = self.get(url, {'build': build.pk, 'available': False}) + + pk_by_bom = {line.bom_item_id: line.pk for line in lines} + + expected_true = {pk_by_bom[bom_sub_ok.pk], pk_by_bom[bom_var_ok.pk]} + expected_false = {pk_by_bom[bom_sub_low.pk], pk_by_bom[bom_var_low.pk]} + + true_ids = {item['pk'] for item in response_true.data} + false_ids = {item['pk'] for item in response_false.data} + + self.assertSetEqual(true_ids, expected_true) + self.assertSetEqual(false_ids, expected_false) + self.assertSetEqual(true_ids | false_ids, {line.pk for line in lines}) + def test_output_options(self): """Test output options for the BuildLine endpoint.""" self.run_output_test( diff --git a/src/frontend/tests/pages/pui_build.spec.ts b/src/frontend/tests/pages/pui_build.spec.ts index 785ecb5465..319554f717 100644 --- a/src/frontend/tests/pages/pui_build.spec.ts +++ b/src/frontend/tests/pages/pui_build.spec.ts @@ -903,4 +903,16 @@ test('Build Order - BOM Quantity', async ({ browser }) => { .locator('div'); const row2 = await getRowFromCell(line); await row2.getByText('1,175').first().waitFor(); + + // Test table filtering against the "Required Parts" table + await clearTableFilters(page); + await page.getByText('1 - 7 / 7').waitFor(); + + // Filter by "available" stock + await setTableChoiceFilter(page, 'Available', 'Yes'); + await page.getByText('1 - 3 / 3').waitFor(); + + await clearTableFilters(page); + await setTableChoiceFilter(page, 'Available', 'No'); + await page.getByText('1 - 4 / 4').waitFor(); });