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