Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/backend/InvenTree/InvenTree/api_version.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
"""InvenTree API version information."""

# InvenTree API version
INVENTREE_API_VERSION = 435
INVENTREE_API_VERSION = 436
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""

INVENTREE_API_TEXT = """

v436 -> 2025-12-19 : https://github.com/inventree/InvenTree/pull/10887
- Extend the "auto allocate" wizard API to include tracked items

v435 -> 2025-12-16 : https://github.com/inventree/InvenTree/pull/11030
- Adds token refresh endpoint to auth API

Expand Down
196 changes: 148 additions & 48 deletions src/backend/InvenTree/build/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
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
Expand Down Expand Up @@ -123,6 +124,13 @@ class MPTTMeta:

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)
Expand Down Expand Up @@ -949,49 +957,10 @@ def create_build_output(self, quantity, **kwargs) -> QuerySet:

# 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)
Expand Down Expand Up @@ -1275,11 +1244,134 @@ def complete_build_output(
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
Expand All @@ -1303,7 +1395,7 @@ def stock_sort(item, bom_item, variant_parts):

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
Expand Down Expand Up @@ -1337,6 +1429,11 @@ def stock_sort(item, bom_item, variant_parts):
# 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='')
Expand Down Expand Up @@ -1679,11 +1776,14 @@ def part(self):
"""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())
)
Expand Down
12 changes: 12 additions & 0 deletions src/backend/InvenTree/build/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1117,6 +1117,17 @@ class Meta:
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
Expand All @@ -1133,6 +1144,7 @@ def save(self):
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'))
Expand Down
25 changes: 25 additions & 0 deletions src/frontend/src/forms/BuildForms.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,31 @@ export function useBuildOrderOutputFields({
}, [quantity, batchGenerator.result, serialGenerator.result, trackable]);
}

export function useBuildAutoAllocateFields({
item_type
}: {
item_type: 'all' | 'tracked' | 'untracked';
}): ApiFormFieldSet {
return useMemo(() => {
return {
location: {},
exclude_location: {},
item_type: {
value: item_type,
hidden: true
},
interchangeable: {
hidden: item_type === 'tracked'
},
substitutes: {},
optional_items: {
hidden: item_type === 'tracked',
value: item_type === 'tracked' ? false : undefined
}
};
}, [item_type]);
}

function BuildOutputFormRow({
props,
record
Expand Down
17 changes: 5 additions & 12 deletions src/frontend/src/tables/build/BuildLineTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type { RowAction, TableColumn } from '@lib/types/Tables';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
import {
useAllocateStockToBuildForm,
useBuildAutoAllocateFields,
useBuildOrderFields,
useConsumeBuildLinesForm
} from '../../forms/BuildForms';
Expand Down Expand Up @@ -573,17 +574,9 @@ export default function BuildLineTable({
url: ApiEndpoints.build_order_auto_allocate,
pk: build.pk,
title: t`Allocate Stock`,
fields: {
location: {
filters: {
structural: false
}
},
exclude_location: {},
interchangeable: {},
substitutes: {},
optional_items: {}
},
fields: useBuildAutoAllocateFields({
item_type: 'untracked'
}),
initialData: {
location: build.take_from,
interchangeable: true,
Expand All @@ -594,7 +587,7 @@ export default function BuildLineTable({
table: table,
preFormContent: (
<Alert color='green' title={t`Auto Allocate Stock`}>
<Text>{t`Automatically allocate stock to this build according to the selected options`}</Text>
<Text>{t`Automatically allocate untracked BOM items to this build according to the selected options`}</Text>
</Alert>
)
});
Expand Down
Loading
Loading