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:
@@ -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')
|
||||
|
||||
|
@@ -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())
|
||||
|
@@ -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."""
|
||||
|
@@ -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."""
|
||||
|
Reference in New Issue
Block a user