mirror of
https://github.com/inventree/InvenTree.git
synced 2026-05-29 12:29:18 +00:00
Auto allocate tracked (#10887)
* Add "item_type" to BuildAutoAllocationSerializer * Update frontend to allow selection * Stub for allocating tracked items * Code for auto-allocating tracked outputs * Refactor auto-allocation code * UI updates * Bump API version * Auto refresh tracked items * Update CHANGELOG.md * docs entry * Add unit test * Add playwright testing
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 456
|
||||
INVENTREE_API_VERSION = 457
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v457 -> 2026-02-11 : https://github.com/inventree/InvenTree/pull/10887
|
||||
- Extend the "auto allocate" wizard API to include tracked items
|
||||
|
||||
v456 -> 2026-02-20 : https://github.com/inventree/InvenTree/pull/11303
|
||||
- Adds "primary" field to the SupplierPart API
|
||||
- Removes "default_supplier" field from the Part API
|
||||
|
||||
@@ -42,6 +42,7 @@ from common.settings import (
|
||||
get_global_setting,
|
||||
prevent_build_output_complete_on_incompleted_tests,
|
||||
)
|
||||
from generic.enums import StringEnum
|
||||
from generic.states import StateTransitionMixin, StatusCodeMixin
|
||||
from plugin.events import trigger_event
|
||||
from stock.status_codes import StockHistoryCode, StockStatus
|
||||
@@ -124,6 +125,13 @@ class Build(
|
||||
|
||||
order_insertion_by = ['reference']
|
||||
|
||||
class BuildItemTypes(StringEnum):
|
||||
"""Enumeration of available item types."""
|
||||
|
||||
ALL = 'all' # All BOM items (both tracked and untracked)
|
||||
TRACKED = 'tracked' # Tracked BOM items
|
||||
UNTRACKED = 'untracked' # Untracked BOM items
|
||||
|
||||
OVERDUE_FILTER = (
|
||||
Q(status__in=BuildStatusGroups.ACTIVE_CODES)
|
||||
& ~Q(target_date=None)
|
||||
@@ -950,49 +958,10 @@ class Build(
|
||||
|
||||
# Auto-allocate stock based on serial number
|
||||
if auto_allocate:
|
||||
for bom_item in trackable_parts:
|
||||
valid_part_ids = valid_parts.get(bom_item.pk, [])
|
||||
|
||||
# Find all matching stock items, based on serial number
|
||||
stock_items = list(
|
||||
stock.models.StockItem.objects.filter(
|
||||
part__pk__in=valid_part_ids,
|
||||
serial=output.serial,
|
||||
quantity=1,
|
||||
)
|
||||
)
|
||||
|
||||
# Filter stock items to only those which are in stock
|
||||
# Note that we can accept "in production" items here
|
||||
available_items = list(
|
||||
filter(
|
||||
lambda item: item.is_in_stock(
|
||||
check_in_production=False
|
||||
),
|
||||
stock_items,
|
||||
)
|
||||
)
|
||||
|
||||
if len(available_items) == 1:
|
||||
stock_item = available_items[0]
|
||||
|
||||
# Find the 'BuildLine' object which points to this BomItem
|
||||
try:
|
||||
build_line = BuildLine.objects.get(
|
||||
build=self, bom_item=bom_item
|
||||
)
|
||||
|
||||
# Allocate the stock items against the BuildLine
|
||||
allocations.append(
|
||||
BuildItem(
|
||||
build_line=build_line,
|
||||
stock_item=stock_item,
|
||||
quantity=1,
|
||||
install_into=output,
|
||||
)
|
||||
)
|
||||
except BuildLine.DoesNotExist:
|
||||
pass
|
||||
if new_allocations := self.auto_allocate_tracked_output(
|
||||
output, location=self.take_from
|
||||
):
|
||||
allocations.extend(new_allocations)
|
||||
|
||||
# Bulk create tracking entries
|
||||
stock.models.StockItemTracking.objects.bulk_create(tracking)
|
||||
@@ -1290,11 +1259,134 @@ class Build(
|
||||
self.save()
|
||||
|
||||
@transaction.atomic
|
||||
def auto_allocate_stock(self, **kwargs):
|
||||
def auto_allocate_stock(
|
||||
self, item_type: str = BuildItemTypes.UNTRACKED, **kwargs
|
||||
) -> None:
|
||||
"""Automatically allocate stock items against this build order.
|
||||
|
||||
Following a number of 'guidelines':
|
||||
- Only "untracked" BOM items are considered (tracked BOM items must be manually allocated)
|
||||
Arguments:
|
||||
item_type: The type of BuildItem to allocate (default = untracked)
|
||||
"""
|
||||
if item_type in [self.BuildItemTypes.UNTRACKED, self.BuildItemTypes.ALL]:
|
||||
self.auto_allocate_untracked_stock(**kwargs)
|
||||
|
||||
if item_type in [self.BuildItemTypes.TRACKED, self.BuildItemTypes.ALL]:
|
||||
self.auto_allocate_tracked_stock(**kwargs)
|
||||
|
||||
def auto_allocate_tracked_output(self, output, **kwargs):
|
||||
"""Auto-allocate tracked stock items against a particular build output.
|
||||
|
||||
This may occur at the time of build output creation, or later when triggered manually.
|
||||
"""
|
||||
location = kwargs.get('location')
|
||||
exclude_location = kwargs.get('exclude_location')
|
||||
substitutes = kwargs.get('substitutes', True)
|
||||
optional_items = kwargs.get('optional_items', False)
|
||||
|
||||
# Newly created allocations (not yet committed to the database)
|
||||
allocations = []
|
||||
|
||||
# Return early if the output should not be auto-allocated
|
||||
if not output.serialized:
|
||||
return allocations
|
||||
|
||||
tracked_line_items = self.tracked_line_items.filter(
|
||||
bom_item__consumable=False, bom_item__sub_part__virtual=False
|
||||
)
|
||||
|
||||
for line_item in tracked_line_items:
|
||||
bom_item = line_item.bom_item
|
||||
|
||||
if bom_item.consumable:
|
||||
# Do not auto-allocate stock to consumable BOM items
|
||||
continue
|
||||
|
||||
if bom_item.optional and not optional_items:
|
||||
# User has specified that optional_items are to be ignored
|
||||
continue
|
||||
|
||||
# If the line item is already fully allocated, we can continue
|
||||
if line_item.is_fully_allocated():
|
||||
continue
|
||||
|
||||
# If there is already allocated stock against this build output, skip it
|
||||
if line_item.allocated_quantity(output=output) > 0:
|
||||
continue
|
||||
|
||||
# Find available parts (may include variants and substitutes)
|
||||
available_parts = bom_item.get_valid_parts_for_allocation(
|
||||
allow_variants=True, allow_substitutes=substitutes
|
||||
)
|
||||
|
||||
# Find stock items which match the output serial number
|
||||
available_stock = stock.models.StockItem.objects.filter(
|
||||
part__in=list(available_parts),
|
||||
part__active=True,
|
||||
part__virtual=False,
|
||||
serial=output.serial,
|
||||
).exclude(Q(serial=None) | Q(serial=''))
|
||||
|
||||
if location:
|
||||
# Filter only stock items located "below" the specified location
|
||||
sublocations = location.get_descendants(include_self=True)
|
||||
available_stock = available_stock.filter(
|
||||
location__in=list(sublocations)
|
||||
)
|
||||
|
||||
if exclude_location:
|
||||
# Exclude any stock items from the provided location
|
||||
sublocations = exclude_location.get_descendants(include_self=True)
|
||||
available_stock = available_stock.exclude(
|
||||
location__in=list(sublocations)
|
||||
)
|
||||
|
||||
# Filter stock items to only those which are in stock
|
||||
# Note that we can accept "in production" items here
|
||||
available_items = list(
|
||||
filter(
|
||||
lambda item: item.is_in_stock(check_in_production=False),
|
||||
available_stock,
|
||||
)
|
||||
)
|
||||
|
||||
if len(available_items) == 1:
|
||||
allocations.append(
|
||||
BuildItem(
|
||||
build_line=line_item,
|
||||
stock_item=available_items[0],
|
||||
quantity=1,
|
||||
install_into=output,
|
||||
)
|
||||
)
|
||||
|
||||
return allocations
|
||||
|
||||
def auto_allocate_tracked_stock(self, **kwargs):
|
||||
"""Automatically allocate tracked stock items against serialized build outputs.
|
||||
|
||||
This function allocates tracked stock items automatically against serialized build outputs,
|
||||
following a set of "guidelines":
|
||||
|
||||
- Only "tracked" BOM items are considered (untracked BOM items must be allocated separately)
|
||||
- Only build outputs with serial numbers are considered
|
||||
- Unallocated tracked components are allocated against build outputs with matching serial numbers
|
||||
"""
|
||||
new_items = []
|
||||
|
||||
# Select only "tracked" line items
|
||||
for output in self.incomplete_outputs.all():
|
||||
new_items.extend(self.auto_allocate_tracked_output(output, **kwargs))
|
||||
|
||||
# Bulk-create the new BuildItem objects
|
||||
BuildItem.objects.bulk_create(new_items)
|
||||
|
||||
def auto_allocate_untracked_stock(self, **kwargs):
|
||||
"""Automatically allocate untracked stock items against this build order.
|
||||
|
||||
This function allocates untracked stock items automatically against a BuildOrder,
|
||||
following a set of "guidelines":
|
||||
|
||||
- Only "untracked" BOM items are considered (tracked BOM items must be allocated separately)
|
||||
- If a particular BOM item is already fully allocated, it is skipped
|
||||
- Extract all available stock items for the BOM part
|
||||
- If variant stock is allowed, extract stock for those too
|
||||
@@ -1318,7 +1410,7 @@ class Build(
|
||||
|
||||
new_items = []
|
||||
|
||||
# Auto-allocation is only possible for "untracked" line items
|
||||
# Select only "untracked" line items
|
||||
for line_item in self.untracked_line_items.all():
|
||||
# Find the referenced BomItem
|
||||
bom_item = line_item.bom_item
|
||||
@@ -1352,6 +1444,11 @@ class Build(
|
||||
# Filter by list of available parts
|
||||
available_stock = available_stock.filter(part__in=list(available_parts))
|
||||
|
||||
# Ensure part is active and not virtual
|
||||
available_stock = available_stock.filter(
|
||||
part__active=True, part__virtual=False
|
||||
)
|
||||
|
||||
# Filter out "serialized" stock items, these cannot be auto-allocated
|
||||
available_stock = available_stock.filter(
|
||||
Q(serial=None) | Q(serial='')
|
||||
@@ -1691,11 +1788,14 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo
|
||||
"""Return the sub_part reference from the link bom_item."""
|
||||
return self.bom_item.sub_part
|
||||
|
||||
def allocated_quantity(self):
|
||||
def allocated_quantity(self, output: Optional[stock.models.StockItem] = None):
|
||||
"""Calculate the total allocated quantity for this BuildLine."""
|
||||
# Queryset containing all BuildItem objects allocated against this BuildLine
|
||||
allocations = self.allocations.all()
|
||||
|
||||
if output is not None:
|
||||
allocations = allocations.filter(install_into=output)
|
||||
|
||||
allocated = allocations.aggregate(
|
||||
q=Coalesce(Sum('quantity'), 0, output_field=models.DecimalField())
|
||||
)
|
||||
|
||||
@@ -1117,6 +1117,17 @@ class BuildAutoAllocationSerializer(serializers.Serializer):
|
||||
help_text=_('Allocate optional BOM items to build order'),
|
||||
)
|
||||
|
||||
item_type = serializers.ChoiceField(
|
||||
default=Build.BuildItemTypes.UNTRACKED,
|
||||
choices=[
|
||||
(Build.BuildItemTypes.ALL, _('All Items')),
|
||||
(Build.BuildItemTypes.UNTRACKED, _('Untracked Items')),
|
||||
(Build.BuildItemTypes.TRACKED, _('Tracked Items')),
|
||||
],
|
||||
label=_('Item Type'),
|
||||
help_text=_('Select item type to auto-allocate'),
|
||||
)
|
||||
|
||||
def save(self):
|
||||
"""Perform the auto-allocation step."""
|
||||
import InvenTree.tasks
|
||||
@@ -1133,6 +1144,7 @@ class BuildAutoAllocationSerializer(serializers.Serializer):
|
||||
interchangeable=data['interchangeable'],
|
||||
substitutes=data['substitutes'],
|
||||
optional_items=data['optional_items'],
|
||||
item_type=data.get('item_type', 'untracked'),
|
||||
group='build',
|
||||
):
|
||||
raise ValidationError(_('Failed to start auto-allocation task'))
|
||||
|
||||
@@ -920,6 +920,77 @@ class BuildAllocationTest(BuildAPITest):
|
||||
self.assertIsNotNone(bi)
|
||||
self.assertEqual(bi.build, build)
|
||||
|
||||
def test_auto_allocate_tracked(self):
|
||||
"""Test manual auto-allocation of tracked items against a Build."""
|
||||
# Create a base assembly
|
||||
assembly = Part.objects.create(
|
||||
name='Test Assembly',
|
||||
description='Test Assembly Description',
|
||||
assembly=True,
|
||||
trackable=True,
|
||||
)
|
||||
|
||||
component = Part.objects.create(
|
||||
name='Test Component',
|
||||
description='Test Component Description',
|
||||
trackable=True,
|
||||
component=True,
|
||||
)
|
||||
|
||||
# Create a BOM item for the assembly
|
||||
BomItem.objects.create(part=assembly, sub_part=component, quantity=1)
|
||||
|
||||
# Create a build order for the assembly
|
||||
build = Build.objects.create(part=assembly, reference='BO-12347', quantity=10)
|
||||
|
||||
SN = '123456'
|
||||
|
||||
# Create serialized component item
|
||||
c = StockItem.objects.create(part=component, quantity=1, serial=SN)
|
||||
|
||||
N = BuildItem.objects.count()
|
||||
|
||||
# Create a new build output
|
||||
response = self.post(
|
||||
reverse('api-build-output-create', kwargs={'pk': build.pk}),
|
||||
{'quantity': 1, 'serial_numbers': SN, 'auto_allocate': False},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
output = response.data[0]
|
||||
|
||||
self.assertIsNotNone(output)
|
||||
self.assertIsNotNone(output['pk'])
|
||||
self.assertEqual(output['serial'], SN)
|
||||
|
||||
# No new build items (allocations) have been created yet
|
||||
self.assertEqual(N, BuildItem.objects.count())
|
||||
|
||||
# Let's auto-allocate via the API now
|
||||
url = reverse('api-build-auto-allocate', kwargs={'pk': build.pk})
|
||||
|
||||
# Allocate only 'untracked' items - this should not allocate our tracked item
|
||||
self.post(url, data={'item_type': 'untracked'})
|
||||
|
||||
self.assertEqual(N, BuildItem.objects.count())
|
||||
|
||||
# Allocate 'tracked' items - this should allocate our tracked item
|
||||
self.post(url, data={'item_type': 'tracked'})
|
||||
|
||||
# A new BuildItem should have been created
|
||||
self.assertEqual(N + 1, BuildItem.objects.count())
|
||||
|
||||
line = build.build_lines.first()
|
||||
|
||||
self.assertIsNotNone(line)
|
||||
allocations = line.allocations.filter(install_into_id=output['pk'])
|
||||
self.assertEqual(allocations.count(), 1)
|
||||
|
||||
allocation = allocations.first()
|
||||
|
||||
self.assertEqual(allocation.stock_item, c)
|
||||
self.assertEqual(allocation.quantity, 1)
|
||||
|
||||
|
||||
class BuildItemTest(BuildAPITest):
|
||||
"""Unit tests for build items.
|
||||
|
||||
Reference in New Issue
Block a user