2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-07-04 14:10:52 +00:00

Mysql filter fix (#12185)

* Improve "available" filter for BuildLine API endpoint

* Fix typo

* Additional unit tests

* Additional playwright tests
This commit is contained in:
Oliver
2026-06-17 14:50:17 +10:00
committed by GitHub
parent a670eabd10
commit 38008d8204
4 changed files with 204 additions and 7 deletions
+21 -5
View File
@@ -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')
+1 -1
View File
@@ -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
+170 -1
View File
@@ -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(
@@ -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();
});