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
+11
View File
@@ -51,6 +51,7 @@ from generic.states import (
inventree_transition,
)
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 stock.events import StockEvents
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:
allocations.append(
BuildItem(
@@ -1903,6 +1909,11 @@ class Build(
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:
# Multiple stock items are available, but they are not interchangeable -
# the user must manually decide how to allocate them.
+7 -1
View File
@@ -74,6 +74,7 @@ from order.status_codes import (
TransferOrderStatusGroups,
)
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 stock.events import StockEvents
from stock.status_codes import StockHistoryCode, StockStatus
@@ -1611,7 +1612,12 @@ class SalesOrder(TotalPriceMixin, Order):
else:
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:
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.helpers import MixinNotImplementedError
from plugin.mixins import (
AllocateMixin,
APICallMixin,
AppMixin,
NavigationMixin,
@@ -241,6 +242,33 @@ class NavigationMixinTest(BaseMixinDefinition, TestCase):
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):
"""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.event.mixins import EventMixin
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.AppMixin import AppMixin
from plugin.base.integration.CurrencyExchangeMixin import CurrencyExchangeMixin
@@ -28,6 +29,7 @@ from plugin.base.ui.mixins import UserInterfaceMixin
__all__ = [
'APICallMixin',
'ActionMixin',
'AllocateMixin',
'AppMixin',
'BarcodeMixin',
'CurrencyExchangeMixin',
+1
View File
@@ -61,6 +61,7 @@ class PluginMixinEnum(StringEnum):
BASE = 'base'
ACTION = 'action'
ALLOCATE = 'allocate'
API_CALL = 'api_call'
APP = 'app'
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)