diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index 92c667e567..61c7209834 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -484,8 +484,8 @@ class BuildLineFilter(FilterSet): def filter_allocated(self, queryset, name, value): """Filter by whether each BuildLine is fully allocated.""" if str2bool(value): - return queryset.filter(allocated__gte=F('quantity')) - return queryset.filter(allocated__lt=F('quantity')) + return queryset.filter(allocated__gte=F('quantity') - F('consumed')) + return queryset.filter(allocated__lt=F('quantity') - F('consumed')) consumed = rest_filters.BooleanFilter(label=_('Consumed'), method='filter_consumed') diff --git a/src/backend/InvenTree/build/filters.py b/src/backend/InvenTree/build/filters.py index 9b995d2de1..d653cc5245 100644 --- a/src/backend/InvenTree/build/filters.py +++ b/src/backend/InvenTree/build/filters.py @@ -1,21 +1,22 @@ """Queryset filtering helper functions for the Build app.""" -from django.db import models -from django.db.models import Q, Sum -from django.db.models.functions import Coalesce +from django.db.models import DecimalField, ExpressionWrapper, F, Max, Sum +from django.db.models.functions import Coalesce, Greatest -def annotate_allocated_quantity(queryset: Q) -> Q: - """Annotate the 'allocated' quantity for each build item in the queryset. - - Arguments: - queryset: The BuildLine queryset to annotate - - """ - queryset = queryset.prefetch_related('allocations') - - return queryset.annotate( - allocated=Coalesce( - Sum('allocations__quantity'), 0, output_field=models.DecimalField() - ) +def annotate_required_quantity(): + """Annotate the 'required' quantity for each build item in the queryset.""" + # Note: The use of Max() here is intentional, to avoid aggregation issues in MySQL + # Ref: https://github.com/inventree/InvenTree/pull/10398 + return Greatest( + ExpressionWrapper( + Max(F('quantity')) - Max(F('consumed')), output_field=DecimalField() + ), + 0, + output_field=DecimalField(), ) + + +def annotate_allocated_quantity(): + """Annotate the 'allocated' quantity for each build item in the queryset.""" + return Coalesce(Sum('allocations__quantity'), 0, output_field=DecimalField()) diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index d0b223c5b2..d7c061a54c 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -29,7 +29,7 @@ import report.mixins import stock.models import users.models from build.events import BuildEvents -from build.filters import annotate_allocated_quantity +from build.filters import annotate_allocated_quantity, annotate_required_quantity from build.status_codes import BuildStatus, BuildStatusGroups from build.validators import ( generate_next_build_reference, @@ -1062,7 +1062,7 @@ class Build( lines = self.untracked_line_items.all() lines = lines.exclude(bom_item__consumable=True) - lines = annotate_allocated_quantity(lines) + lines = lines.annotate(allocated=annotate_allocated_quantity()) for build_line in lines: # type: ignore[non-iterable] reduce_by = build_line.allocated - build_line.quantity @@ -1381,10 +1381,12 @@ class Build( elif tracked is False: lines = lines.filter(bom_item__sub_part__trackable=False) - lines = annotate_allocated_quantity(lines) + lines = lines.prefetch_related('allocations') - # Filter out any lines which have been fully allocated - lines = lines.filter(allocated__lt=F('quantity')) + lines = lines.annotate( + allocated=annotate_allocated_quantity(), + required=annotate_required_quantity(), + ).filter(allocated__lt=F('required')) return lines @@ -1436,10 +1438,14 @@ class Build( True if any BuildLine has been over-allocated. """ lines = self.build_lines.all().exclude(bom_item__consumable=True) - lines = annotate_allocated_quantity(lines) + + lines = lines.prefetch_related('allocations') # Find any lines which have been over-allocated - lines = lines.filter(allocated__gt=F('quantity')) + lines = lines.annotate( + allocated=annotate_allocated_quantity(), + required=annotate_required_quantity(), + ).filter(allocated__gt=F('required')) return lines.count() > 0 @@ -1644,19 +1650,30 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo return allocated['q'] def unallocated_quantity(self): - """Return the unallocated quantity for this BuildLine.""" - return max(self.quantity - self.allocated_quantity(), 0) + """Return the unallocated quantity for this BuildLine. + + - Start with the required quantity + - Subtract the consumed quantity + - Subtract the allocated quantity + + Return the remaining quantity (or zero if negative) + """ + return max(self.quantity - self.consumed - self.allocated_quantity(), 0) def is_fully_allocated(self): """Return True if this BuildLine is fully allocated.""" if self.bom_item.consumable: return True - return self.allocated_quantity() >= self.quantity + required = max(0, self.quantity - self.consumed) + + return self.allocated_quantity() >= required def is_overallocated(self): """Return True if this BuildLine is over-allocated.""" - return self.allocated_quantity() > self.quantity + required = max(0, self.quantity - self.consumed) + + return self.allocated_quantity() > required def is_fully_consumed(self): """Return True if this BuildLine is fully consumed.""" diff --git a/src/backend/InvenTree/build/test_build.py b/src/backend/InvenTree/build/test_build.py index d73989965b..608bff91b4 100644 --- a/src/backend/InvenTree/build/test_build.py +++ b/src/backend/InvenTree/build/test_build.py @@ -853,6 +853,78 @@ class AutoAllocationTests(BuildTestBase): self.assertEqual(self.line_1.unallocated_quantity(), 0) self.assertEqual(self.line_2.unallocated_quantity(), 0) + def test_allocate_consumed(self): + """Test for auto-allocation against a build which has been fully consumed. + + Steps: + 1. Fully allocate the build (using the auto-allocate function) + 2. Consume allocated stock + 3. Ensure that all allocations are removed + 4. Re-run the auto-allocate function + 5. Check that no new allocations have been made + """ + self.assertEqual(self.build.allocated_stock.count(), 0) + self.assertFalse(self.build.is_fully_allocated(tracked=False)) + + # Auto allocate stock against the build order + self.build.auto_allocate_stock( + interchangeable=True, substitutes=True, optional_items=True + ) + + self.assertEqual(self.line_1.allocated_quantity(), 50) + self.assertEqual(self.line_2.allocated_quantity(), 30) + + self.assertEqual(self.line_1.unallocated_quantity(), 0) + self.assertEqual(self.line_2.unallocated_quantity(), 0) + + self.assertTrue(self.line_1.is_fully_allocated()) + self.assertTrue(self.line_2.is_fully_allocated()) + + self.assertFalse(self.line_1.is_overallocated()) + self.assertFalse(self.line_2.is_overallocated()) + + N = self.build.allocated_stock.count() + + self.assertEqual(self.line_1.allocations.count(), 2) + self.assertEqual(self.line_2.allocations.count(), 6) + + for item in self.line_1.allocations.all(): + item.complete_allocation() + + for item in self.line_2.allocations.all(): + item.complete_allocation() + + self.line_1.refresh_from_db() + self.line_2.refresh_from_db() + + self.assertTrue(self.line_1.is_fully_allocated()) + self.assertTrue(self.line_2.is_fully_allocated()) + self.assertFalse(self.line_1.is_overallocated()) + self.assertFalse(self.line_2.is_overallocated()) + + self.assertEqual(self.line_1.allocations.count(), 0) + self.assertEqual(self.line_2.allocations.count(), 0) + + self.assertEqual(self.line_1.quantity, self.line_1.consumed) + self.assertEqual(self.line_2.quantity, self.line_2.consumed) + + # Check that the "allocations" have been removed + self.assertEqual(self.build.allocated_stock.count(), N - 8) + + # Now, try to auto-allocate again + self.build.auto_allocate_stock( + interchangeable=True, substitutes=True, optional_items=True + ) + + # Ensure that there are no "new" allocations (there should be none!) + self.assertEqual(self.line_1.allocated_quantity(), 0) + self.assertEqual(self.line_2.allocated_quantity(), 0) + + self.assertEqual(self.line_1.unallocated_quantity(), 0) + self.assertEqual(self.line_2.unallocated_quantity(), 0) + + self.assertEqual(self.build.allocated_stock.count(), N - 8) + class ExternalBuildTest(InvenTreeAPITestCase): """Unit tests for external build order functionality."""