2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-03-04 03:11:46 +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

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