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):
"""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')

View File

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

View File

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

View File

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