2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-02-25 16:17:58 +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:
Oliver
2026-02-22 20:15:31 +11:00
committed by GitHub
parent cca35bb268
commit 2e22245255
10 changed files with 350 additions and 64 deletions

View File

@@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[#11383](https://github.com/inventree/InvenTree/pull/11383) adds "exists_for_model_id", "exists_for_related_model", and "exists_for_related_model_id" filters to the ParameterTemplate API endpoint. These filters allow users to check for the existence of parameters associated with specific models or related models, improving the flexibility and usability of the API.
[#10887](https://github.com/inventree/InvenTree/pull/10887) adds the ability to auto-allocate tracked items against specific build outputs. Currently, this will only allocate items where the serial number of the tracked item matches the serial number of the build output, but in future this may be extended to allow for more flexible allocation rules.
### Changed
### Removed

View File

@@ -108,9 +108,9 @@ Set this option to *True* to allow substitute parts (as specified by the BOM) to
Allocation of tracked stock items is slightly more complex. Instead of being allocated against the *Build Order*, tracked stock items must be allocated against an individual *Build Output*.
Allocating tracked stock items to particular build outputs is performed in the *Pending Items* tab:
Allocating tracked stock items to particular build outputs is performed in the *Incomplete Outputs* tab:
In the *Pending Items* tab, we can see that each build output has a stock allocation requirement which must be met before that build output can be completed:
In the *Incomplete Outputs* tab, we can see that each build output has a stock allocation requirement which must be met before that build output can be completed:
{{ image("build/build_allocate_tracked_parts.png", "Allocate tracked parts") }}
@@ -126,6 +126,12 @@ Here we can see that the incomplete build outputs (serial numbers 15 and 14) now
!!! note "Example: Tracked Stock"
Let's say we have 5 units of "Tracked Part" in stock - with 1 unit allocated to the build output. Once we complete the build output, there will be 4 units of "Tracked Part" in stock, with 1 unit being marked as "installed" within the assembled part
### Automatic Stock Allocation
Tracked stock items can be automatically allocated to build outputs using the *Auto Allocate* button in the *Incomplete Outputs* tab. This will attempt to allocate tracked stock items to build outputs based on matching serial numbers.
For each build output, the auto-allocation routine will attempt to find a matching component item with the same serial number. If such a stock item is found, and it is available for use, it will be allocated to that build output.
## Consuming Stock
Allocating stock items to a build order does not immediately remove them from stock. Instead, the stock items are marked as "allocated" against the build order, and are only removed from stock when they are "consumed" by the build order.

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,

View File

@@ -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';
@@ -574,17 +575,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,
@@ -595,7 +588,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>
)
});

View File

@@ -14,7 +14,8 @@ import {
IconBuildingFactory2,
IconCircleCheck,
IconCircleX,
IconExclamationCircle
IconExclamationCircle,
IconWand
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo, useState } from 'react';
@@ -33,6 +34,7 @@ import type { TableColumn } from '@lib/types/Tables';
import { StylishText } from '../../components/items/StylishText';
import { useApi } from '../../contexts/ApiContext';
import {
useBuildAutoAllocateFields,
useBuildOrderOutputFields,
useCancelBuildOutputsForm,
useCompleteBuildOutputsForm,
@@ -213,6 +215,32 @@ export default function BuildOutputTable({
}
});
const autoAllocateStock = useCreateApiFormModal({
url: ApiEndpoints.build_order_auto_allocate,
pk: build.pk,
title: t`Allocate Stock`,
fields: useBuildAutoAllocateFields({
item_type: 'tracked'
}),
initialData: {
location: build.take_from,
substitutes: true
},
successMessage: t`Auto-allocation in progress`,
onFormSuccess: () => {
// After a short delay, refresh the tracked items
setTimeout(() => {
refetchTrackedItems();
}, 2500);
},
table: table,
preFormContent: (
<Alert color='green' title={t`Auto Allocate Stock`}>
<Text>{t`Automatically allocate tracked BOM items to this build according to the selected options`}</Text>
</Alert>
)
});
const hasTrackedItems: boolean = useMemo(() => {
return (trackedItems?.length ?? 0) > 0;
}, [trackedItems]);
@@ -438,6 +466,16 @@ export default function BuildOutputTable({
const tableActions = useMemo(() => {
return [
stockAdjustActions.dropdown,
<ActionButton
key='allocate-stock'
icon={<IconWand />}
color='blue'
tooltip={t`Auto Allocate Stock`}
hidden={!hasTrackedItems}
onClick={() => {
autoAllocateStock.open();
}}
/>,
<ActionButton
key='complete-selected-outputs'
tooltip={t`Complete selected outputs`}
@@ -480,6 +518,7 @@ export default function BuildOutputTable({
];
}, [
build,
hasTrackedItems,
user,
table.selectedRecords,
table.hasSelectedRecords,
@@ -680,6 +719,7 @@ export default function BuildOutputTable({
return (
<>
{addBuildOutput.modal}
{autoAllocateStock.modal}
{completeBuildOutputsForm.modal}
{scrapBuildOutputsForm.modal}
{editBuildOutput.modal}

View File

@@ -431,6 +431,40 @@ test('Build Order - Allocation', async ({ browser }) => {
.waitFor();
});
test('Build Order - Auto Allocate Tracked', async ({ browser }) => {
const page = await doCachedLogin(browser, {
url: 'manufacturing/build-order/27/consumed-stock'
});
await loadTab(page, 'Incomplete Outputs');
await page.getByRole('cell', { name: '0 / 6' }).waitFor();
// Auto-allocate tracked stock
await page
.getByRole('button', { name: 'action-button-auto-allocate-' })
.click();
// Wait for auto-filled form field
await page
.locator('div')
.filter({ hasText: /^Factory$/ })
.first()
.waitFor();
await page.getByRole('button', { name: 'Submit' }).click();
// Wait for one of the required parts to be allocated
await page.getByRole('cell', { name: '1 / 6' }).waitFor({ timeout: 7500 });
// Deallocate the item to return to the initial state
const cell = await page.getByRole('cell', { name: '# 555' });
await clickOnRowMenu(cell);
await page.getByRole('menuitem', { name: 'Deallocate' }).click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('cell', { name: '0 / 6' }).waitFor({ timeout: 7500 });
});
// Test partial stock consumption against build order
test('Build Order - Consume Stock', async ({ browser }) => {
const page = await doCachedLogin(browser, {