Allocate mixin (#12914)

* Create AllocateMixin plugin mixin class

* Add documentation

* Add hook-in points for the new code

* Add unit tests

* Add CHANGELOG entry

* Add loose typing
This commit is contained in:
Oliver
2026-09-23 06:39:26 +10:00
committed by GitHub
parent 5edf72e084
commit a4518fb52b
12 changed files with 322 additions and 1 deletions
+1
View File
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [#12713](https://github.com/inventree/InvenTree/pull/12713) adds SCIM 2 provisioning support, allowing InvenTree to be integrated with external identity providers for user management. - [#12713](https://github.com/inventree/InvenTree/pull/12713) adds SCIM 2 provisioning support, allowing InvenTree to be integrated with external identity providers for user management.
- [#12731](https://github.com/inventree/InvenTree/pull/12731) adds OIDC provider settings to the Admin Center - making all Identity Federation settings now available in one place without the need to use the database admin interface. - [#12731](https://github.com/inventree/InvenTree/pull/12731) adds OIDC provider settings to the Admin Center - making all Identity Federation settings now available in one place without the need to use the database admin interface.
- [#12837](https://github.com/inventree/InvenTree/pull/12837) adds a user setting `ROTATE_TABLE_HEADERS` which rotates table headers by 90 degrees, improving readability for tables with long column titles. - [#12837](https://github.com/inventree/InvenTree/pull/12837) adds a user setting `ROTATE_TABLE_HEADERS` which rotates table headers by 90 degrees, improving readability for tables with long column titles.
- [#12914](https://github.com/inventree/InvenTree/pull/12914) adds the AllocateMixin, allowing plugins to customize automatic stock allocation for build orders and sales orders.
### Changed ### Changed
+1
View File
@@ -127,6 +127,7 @@ Supported mixin classes are:
| Mixin | Description | | Mixin | Description |
| --- | --- | | --- | --- |
| [ActionMixin](./mixins/action.md) | Run custom actions | | [ActionMixin](./mixins/action.md) | Run custom actions |
| [AllocateMixin](./mixins/allocate.md) | Customize automatic stock allocation |
| [APICallMixin](./mixins/api.md) | Perform calls to external APIs | | [APICallMixin](./mixins/api.md) | Perform calls to external APIs |
| [AppMixin](./mixins/app.md) | Integrate additional database tables | | [AppMixin](./mixins/app.md) | Integrate additional database tables |
| [BarcodeMixin](./mixins/barcode.md) | Support custom barcode actions | | [BarcodeMixin](./mixins/barcode.md) | Support custom barcode actions |
+57
View File
@@ -0,0 +1,57 @@
---
title: Allocate Mixin
---
## AllocateMixin
The `AllocateMixin` class enables plugins to customize how stock items are automatically allocated against [build orders](../../manufacturing/build.md) and [sales orders](../../sales/sales_order.md).
When a user triggers "auto allocation" of stock against an order, InvenTree first determines a list of candidate stock items for each line, using the default allocation rules (e.g. in-stock, matching part / variant / substitute, location, serialization, etc). Before this candidate list is used to actually create the stock allocations, it is passed through any active plugins which implement the `AllocateMixin` class - allowing a plugin to filter, reorder, or otherwise adjust which stock items are used.
!!! info "Multi Plugin Support"
If multiple plugins are active which implement the `AllocateMixin` methods, they are called in turn - each plugin receives the (possibly already adjusted) output of the previous plugin.
!!! info "Default Behavior"
Neither method needs to be implemented by a plugin. If a method is not overridden - or if it returns `None` - the provided list of stock items is passed through unmodified.
### Build Order Allocation
The `filter_build_allocation` method is called when automatically allocating stock against a [build order](../../manufacturing/build.md) - for both "tracked" and "untracked" stock items.
Note that this method is called *once per candidate list* - once for each `BuildLine` (untracked stock), and once for each tracked build output.
::: plugin.base.integration.AllocateMixin.AllocateMixin.filter_build_allocation
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
extra:
show_source: True
summary: False
members: []
### Sales Order Allocation
The `filter_sales_order_allocation` method is called when automatically allocating stock against a [sales order](../../sales/sales_order.md).
::: plugin.base.integration.AllocateMixin.AllocateMixin.filter_sales_order_allocation
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
extra:
show_source: True
summary: False
members: []
### Sample Plugin
A sample plugin which implements custom allocation filtering is provided in the InvenTree source code. It excludes any stock item with a batch code of `REJECT` from being automatically allocated:
::: plugin.samples.integration.allocate_sample.SampleAllocatePlugin
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
show_source: True
members: []
+1
View File
@@ -218,6 +218,7 @@ nav:
- Unit Test: plugins/test.md - Unit Test: plugins/test.md
- Plugin Mixins: - Plugin Mixins:
- Action Mixin: plugins/mixins/action.md - Action Mixin: plugins/mixins/action.md
- Allocate Mixin: plugins/mixins/allocate.md
- API Mixin: plugins/mixins/api.md - API Mixin: plugins/mixins/api.md
- App Mixin: plugins/mixins/app.md - App Mixin: plugins/mixins/app.md
- Barcode Mixin: plugins/mixins/barcode.md - Barcode Mixin: plugins/mixins/barcode.md
+11
View File
@@ -51,6 +51,7 @@ from generic.states import (
inventree_transition, inventree_transition,
) )
from InvenTree.helpers_db import bulk_create_and_fetch from InvenTree.helpers_db import bulk_create_and_fetch
from plugin.base.integration.AllocateMixin import apply_allocate_mixin
from plugin.events import bulk_trigger_event, trigger_event from plugin.events import bulk_trigger_event, trigger_event
from stock.events import StockEvents from stock.events import StockEvents
from stock.status_codes import StockHistoryCode, StockStatus from stock.status_codes import StockHistoryCode, StockStatus
@@ -1730,6 +1731,11 @@ class Build(
) )
) )
# Allow plugins to filter / reorder the candidate stock items
available_items = apply_allocate_mixin(
'filter_build_allocation', line_item, available_items, **kwargs
)
if len(available_items) == 1: if len(available_items) == 1:
allocations.append( allocations.append(
BuildItem( BuildItem(
@@ -1903,6 +1909,11 @@ class Build(
key=lambda item, b=bom_item, v=variant_parts: stock_sort(item, b, v), key=lambda item, b=bom_item, v=variant_parts: stock_sort(item, b, v),
) )
# Allow plugins to filter / reorder the candidate stock items
available_stock = apply_allocate_mixin(
'filter_build_allocation', line_item, available_stock, **kwargs
)
if len(available_stock) != 1 and not interchangeable: if len(available_stock) != 1 and not interchangeable:
# Multiple stock items are available, but they are not interchangeable - # Multiple stock items are available, but they are not interchangeable -
# the user must manually decide how to allocate them. # the user must manually decide how to allocate them.
+7 -1
View File
@@ -74,6 +74,7 @@ from order.status_codes import (
TransferOrderStatusGroups, TransferOrderStatusGroups,
) )
from part import models as PartModels from part import models as PartModels
from plugin.base.integration.AllocateMixin import apply_allocate_mixin
from plugin.events import bulk_trigger_event, trigger_event from plugin.events import bulk_trigger_event, trigger_event
from stock.events import StockEvents from stock.events import StockEvents
from stock.status_codes import StockHistoryCode, StockStatus from stock.status_codes import StockHistoryCode, StockStatus
@@ -1611,7 +1612,12 @@ class SalesOrder(TotalPriceMixin, Order):
else: else:
available_stock = available_stock.order_by(stock_sort_by) available_stock = available_stock.order_by(stock_sort_by)
stock_count = available_stock.count() # Allow plugins to filter / reorder the candidate stock items
available_stock = apply_allocate_mixin(
'filter_sales_order_allocation', line_item, available_stock, **kwargs
)
stock_count = len(available_stock)
if stock_count == 0: if stock_count == 0:
continue continue
@@ -0,0 +1,108 @@
"""Plugin mixin class for AllocateMixin."""
from django.db.models import Model
from InvenTree.exceptions import log_error
from plugin import PluginMixinEnum
class AllocateMixin:
"""Mixin which allows plugins to customize automatic stock allocation.
This mixin acts as a "shim" during the auto-allocation of stock items
against build orders and sales orders. It is called *after* the default
allocation logic has determined a list of candidate stock items, but
*before* those items are actually used for allocation.
This allows a plugin to filter, reorder, or otherwise adjust the list
of candidate stock items - for example, to implement a custom picking
strategy, or to exclude certain stock items from automatic allocation.
"""
class MixinMeta:
"""Meta options for this mixin."""
MIXIN_NAME = 'Allocate'
def __init__(self):
"""Register mixin."""
super().__init__()
self.add_mixin(PluginMixinEnum.ALLOCATE, True, __class__)
def filter_build_allocation(
self, build_line: Model, stock_items: list, **kwargs
) -> list:
"""Filter the stock items available for auto-allocation against a build order.
Arguments:
build_line: The BuildLine object which is being allocated against
stock_items: A list of candidate StockItem objects, which have already
been filtered against the default allocation rules (e.g. in-stock,
matching part / variant / substitute, location, etc)
Returns:
A list of StockItem objects to be used for auto-allocation.
The default implementation simply returns the provided list of stock items,
unmodified.
"""
return stock_items
def filter_sales_order_allocation(
self, order_line: Model, stock_items: list, **kwargs
) -> list:
"""Filter the stock items available for auto-allocation against a sales order.
Arguments:
order_line: The SalesOrderLineItem object which is being allocated against
stock_items: A list of candidate StockItem objects, which have already
been filtered against the default allocation rules (e.g. in-stock,
matching part, location, serialization, etc)
Returns:
A list of StockItem objects to be used for auto-allocation.
The default implementation simply returns the provided list of stock items,
unmodified.
"""
return stock_items
def apply_allocate_mixin(
hook_name: str, line_item, stock_items: list, **kwargs
) -> list:
"""Run the named AllocateMixin hook against every active implementing plugin.
Arguments:
hook_name: Name of the AllocateMixin method to call
(e.g. 'filter_build_allocation' or 'filter_sales_order_allocation')
line_item: The BuildLine / SalesOrderLineItem being allocated against
stock_items: The current list of candidate StockItem objects
Returns:
The (possibly modified) list of candidate StockItem objects, after being
passed through every active plugin which implements the AllocateMixin.
Each active plugin is given the opportunity to filter / reorder the list,
receiving the output of the previous plugin as its input. If a plugin raises
an exception, or returns a non-list value, its result is discarded and the
list is passed unmodified to the next plugin.
"""
from plugin import registry
stock_items = list(stock_items)
for plg in registry.with_mixin(PluginMixinEnum.ALLOCATE):
try:
result = getattr(plg, hook_name)(line_item, stock_items, **kwargs)
except Exception:
log_error(hook_name, plugin=plg.slug)
continue
if result is not None:
try:
stock_items = list(result)
except Exception:
log_error(hook_name, plugin=plg.slug)
return stock_items
@@ -13,6 +13,7 @@ from InvenTree.unit_test import InvenTreeTestCase
from plugin import InvenTreePlugin from plugin import InvenTreePlugin
from plugin.helpers import MixinNotImplementedError from plugin.helpers import MixinNotImplementedError
from plugin.mixins import ( from plugin.mixins import (
AllocateMixin,
APICallMixin, APICallMixin,
AppMixin, AppMixin,
NavigationMixin, NavigationMixin,
@@ -241,6 +242,33 @@ class NavigationMixinTest(BaseMixinDefinition, TestCase):
NavigationCls() NavigationCls()
class AllocateMixinTest(BaseMixinDefinition, TestCase):
"""Tests for AllocateMixin."""
MIXIN_HUMAN_NAME = 'Allocate'
MIXIN_NAME = 'allocate'
MIXIN_ENABLE_CHECK = 'has_allocate'
def setUp(self):
"""Setup for all tests."""
class AllocateCls(AllocateMixin, InvenTreePlugin):
pass
self.mixin = AllocateCls()
def test_function(self):
"""Test that the default hook implementations are pass-through."""
stock_items = ['a', 'b', 'c']
self.assertEqual(
self.mixin.filter_build_allocation(None, stock_items), stock_items
)
self.assertEqual(
self.mixin.filter_sales_order_allocation(None, stock_items), stock_items
)
class APICallMixinTest(BaseMixinDefinition, TestCase): class APICallMixinTest(BaseMixinDefinition, TestCase):
"""Tests for APICallMixin.""" """Tests for APICallMixin."""
@@ -4,6 +4,7 @@ from plugin.base.action.mixins import ActionMixin
from plugin.base.barcodes.mixins import BarcodeMixin, SupplierBarcodeMixin from plugin.base.barcodes.mixins import BarcodeMixin, SupplierBarcodeMixin
from plugin.base.event.mixins import EventMixin from plugin.base.event.mixins import EventMixin
from plugin.base.icons.mixins import IconPackMixin from plugin.base.icons.mixins import IconPackMixin
from plugin.base.integration.AllocateMixin import AllocateMixin
from plugin.base.integration.APICallMixin import APICallMixin from plugin.base.integration.APICallMixin import APICallMixin
from plugin.base.integration.AppMixin import AppMixin from plugin.base.integration.AppMixin import AppMixin
from plugin.base.integration.CurrencyExchangeMixin import CurrencyExchangeMixin from plugin.base.integration.CurrencyExchangeMixin import CurrencyExchangeMixin
@@ -28,6 +29,7 @@ from plugin.base.ui.mixins import UserInterfaceMixin
__all__ = [ __all__ = [
'APICallMixin', 'APICallMixin',
'ActionMixin', 'ActionMixin',
'AllocateMixin',
'AppMixin', 'AppMixin',
'BarcodeMixin', 'BarcodeMixin',
'CurrencyExchangeMixin', 'CurrencyExchangeMixin',
+1
View File
@@ -61,6 +61,7 @@ class PluginMixinEnum(StringEnum):
BASE = 'base' BASE = 'base'
ACTION = 'action' ACTION = 'action'
ALLOCATE = 'allocate'
API_CALL = 'api_call' API_CALL = 'api_call'
APP = 'app' APP = 'app'
BARCODE = 'barcode' BARCODE = 'barcode'
@@ -0,0 +1,31 @@
"""Sample plugin which demonstrates custom stock allocation functionality."""
from plugin import InvenTreePlugin
from plugin.mixins import AllocateMixin
# Batch code which marks a stock item as excluded from auto-allocation
REJECT_BATCH_CODE = 'REJECT'
class SampleAllocatePlugin(AllocateMixin, InvenTreePlugin):
"""A sample plugin for demonstrating custom auto-allocation behavior.
Any stock item with a batch code of 'REJECT' is excluded from
auto-allocation, for both build orders and sales orders.
"""
NAME = 'SampleAllocate'
SLUG = 'sampleallocate'
TITLE = 'Sample Allocate Plugin'
DESCRIPTION = (
'A sample plugin for demonstrating custom stock allocation functionality'
)
VERSION = '0.1.0'
def filter_build_allocation(self, build_line, stock_items, **kwargs):
"""Exclude any stock item with a 'REJECT' batch code."""
return [item for item in stock_items if item.batch != REJECT_BATCH_CODE]
def filter_sales_order_allocation(self, order_line, stock_items, **kwargs):
"""Exclude any stock item with a 'REJECT' batch code."""
return [item for item in stock_items if item.batch != REJECT_BATCH_CODE]
@@ -0,0 +1,74 @@
"""Unit tests for the SampleAllocatePlugin class."""
from build.models import Build, BuildLine, generate_next_build_reference
from company.models import Company
from InvenTree.unit_test import InvenTreeTestCase
from order.models import SalesOrder, SalesOrderLineItem
from part.models import BomItem, Part
from plugin.registry import registry
from stock.models import StockItem
class SampleAllocatePluginTest(InvenTreeTestCase):
"""Tests for the SampleAllocatePlugin class."""
def enable_plugin(self, en: bool):
"""Enable or disable the SampleAllocatePlugin."""
registry.set_plugin_state('sampleallocate', en)
def test_build_auto_allocate(self):
"""The plugin should exclude 'REJECT' batches from build order allocation."""
assembly = Part.objects.create(name='Assembly', assembly=True)
component = Part.objects.create(name='Component', component=True)
BomItem.objects.create(part=assembly, sub_part=component, quantity=5)
build = Build.objects.create(
reference=generate_next_build_reference(), part=assembly, quantity=1
)
line = BuildLine.objects.get(build=build)
good_stock = StockItem.objects.create(part=component, quantity=10)
StockItem.objects.create(part=component, quantity=10, batch='REJECT')
# With the plugin disabled, either stock item may be selected - not interchangeable
self.enable_plugin(False)
build.auto_allocate_stock(interchangeable=False)
self.assertEqual(line.allocated_quantity(), 0)
# With the plugin enabled, the 'REJECT' item is filtered out, leaving a single
# (interchangeable) candidate, which can then be allocated
self.enable_plugin(True)
build.auto_allocate_stock(interchangeable=False)
line.refresh_from_db()
self.assertEqual(line.allocated_quantity(), 5)
self.assertEqual(
list(build.allocated_stock.values_list('stock_item', flat=True)),
[good_stock.pk],
)
self.enable_plugin(False)
def test_sales_order_auto_allocate(self):
"""The plugin should exclude 'REJECT' batches from sales order allocation."""
customer = Company.objects.create(name='Customer', is_customer=True)
part = Part.objects.create(name='Widget', salable=True)
order = SalesOrder.objects.create(customer=customer)
line = SalesOrderLineItem.objects.create(order=order, part=part, quantity=5)
good_stock = StockItem.objects.create(part=part, quantity=10)
StockItem.objects.create(part=part, quantity=10, batch='REJECT')
self.enable_plugin(True)
order.auto_allocate_stock(interchangeable=False)
self.assertTrue(line.is_fully_allocated())
self.assertEqual(
list(order.stock_allocations.values_list('item', flat=True)),
[good_stock.pk],
)
self.enable_plugin(False)