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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user