2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-10-03 15:52:51 +00:00

[bug] Auto allocate bugfix (#10398)

* Fix "unallocated_quantity" calculation

- Take "consumed" quantity into account also

* Account for consumed quantity in:

- build.is_fully_allocated
- build.is_overallocated

* Additional unit tests

- Ensure the new calculations work properly

* Adjust API filter

* Try splitting query

* Another fix

* Try ExpressionWrapper

* Change order of operations?

* Refactor

* Adjust filtering strategy

* Change ordering

* Use Max wrapper

* Add comments
This commit is contained in:
Oliver
2025-09-27 10:10:16 +10:00
committed by GitHub
parent 52be30eef5
commit 6fdc6b3a8c
4 changed files with 119 additions and 29 deletions

View File

@@ -484,8 +484,8 @@ class BuildLineFilter(FilterSet):
def filter_allocated(self, queryset, name, value): def filter_allocated(self, queryset, name, value):
"""Filter by whether each BuildLine is fully allocated.""" """Filter by whether each BuildLine is fully allocated."""
if str2bool(value): if str2bool(value):
return queryset.filter(allocated__gte=F('quantity')) return queryset.filter(allocated__gte=F('quantity') - F('consumed'))
return queryset.filter(allocated__lt=F('quantity')) return queryset.filter(allocated__lt=F('quantity') - F('consumed'))
consumed = rest_filters.BooleanFilter(label=_('Consumed'), method='filter_consumed') consumed = rest_filters.BooleanFilter(label=_('Consumed'), method='filter_consumed')

View File

@@ -1,21 +1,22 @@
"""Queryset filtering helper functions for the Build app.""" """Queryset filtering helper functions for the Build app."""
from django.db import models from django.db.models import DecimalField, ExpressionWrapper, F, Max, Sum
from django.db.models import Q, Sum from django.db.models.functions import Coalesce, Greatest
from django.db.models.functions import Coalesce
def annotate_allocated_quantity(queryset: Q) -> Q: def annotate_required_quantity():
"""Annotate the 'allocated' quantity for each build item in the queryset. """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
Arguments: # Ref: https://github.com/inventree/InvenTree/pull/10398
queryset: The BuildLine queryset to annotate return Greatest(
ExpressionWrapper(
""" Max(F('quantity')) - Max(F('consumed')), output_field=DecimalField()
queryset = queryset.prefetch_related('allocations') ),
0,
return queryset.annotate( output_field=DecimalField(),
allocated=Coalesce(
Sum('allocations__quantity'), 0, output_field=models.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())

View File

@@ -29,7 +29,7 @@ import report.mixins
import stock.models import stock.models
import users.models import users.models
from build.events import BuildEvents 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.status_codes import BuildStatus, BuildStatusGroups
from build.validators import ( from build.validators import (
generate_next_build_reference, generate_next_build_reference,
@@ -1062,7 +1062,7 @@ class Build(
lines = self.untracked_line_items.all() lines = self.untracked_line_items.all()
lines = lines.exclude(bom_item__consumable=True) 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] for build_line in lines: # type: ignore[non-iterable]
reduce_by = build_line.allocated - build_line.quantity reduce_by = build_line.allocated - build_line.quantity
@@ -1381,10 +1381,12 @@ class Build(
elif tracked is False: elif tracked is False:
lines = lines.filter(bom_item__sub_part__trackable=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.annotate(
lines = lines.filter(allocated__lt=F('quantity')) allocated=annotate_allocated_quantity(),
required=annotate_required_quantity(),
).filter(allocated__lt=F('required'))
return lines return lines
@@ -1436,10 +1438,14 @@ class Build(
True if any BuildLine has been over-allocated. True if any BuildLine has been over-allocated.
""" """
lines = self.build_lines.all().exclude(bom_item__consumable=True) 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 # 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 return lines.count() > 0
@@ -1644,19 +1650,30 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo
return allocated['q'] return allocated['q']
def unallocated_quantity(self): def unallocated_quantity(self):
"""Return the unallocated quantity for this BuildLine.""" """Return the unallocated quantity for this BuildLine.
return max(self.quantity - self.allocated_quantity(), 0)
- 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): def is_fully_allocated(self):
"""Return True if this BuildLine is fully allocated.""" """Return True if this BuildLine is fully allocated."""
if self.bom_item.consumable: if self.bom_item.consumable:
return True return True
return self.allocated_quantity() >= self.quantity required = max(0, self.quantity - self.consumed)
return self.allocated_quantity() >= required
def is_overallocated(self): def is_overallocated(self):
"""Return True if this BuildLine is over-allocated.""" """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): def is_fully_consumed(self):
"""Return True if this BuildLine is fully consumed.""" """Return True if this BuildLine is fully consumed."""

View File

@@ -853,6 +853,78 @@ class AutoAllocationTests(BuildTestBase):
self.assertEqual(self.line_1.unallocated_quantity(), 0) self.assertEqual(self.line_1.unallocated_quantity(), 0)
self.assertEqual(self.line_2.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): class ExternalBuildTest(InvenTreeAPITestCase):
"""Unit tests for external build order functionality.""" """Unit tests for external build order functionality."""