From a4518fb52b47a04f2ca50a7c7a7360c6e04df5f8 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 23 Sep 2026 06:39:26 +1000 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + docs/docs/plugins/develop.md | 1 + docs/docs/plugins/mixins/allocate.md | 57 +++++++++ docs/mkdocs.yml | 1 + src/backend/InvenTree/build/models.py | 11 ++ src/backend/InvenTree/order/models.py | 8 +- .../plugin/base/integration/AllocateMixin.py | 108 ++++++++++++++++++ .../plugin/base/integration/test_mixins.py | 28 +++++ .../InvenTree/plugin/mixins/__init__.py | 2 + src/backend/InvenTree/plugin/plugin.py | 1 + .../samples/integration/allocate_sample.py | 31 +++++ .../integration/test_allocate_sample.py | 74 ++++++++++++ 12 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 docs/docs/plugins/mixins/allocate.md create mode 100644 src/backend/InvenTree/plugin/base/integration/AllocateMixin.py create mode 100644 src/backend/InvenTree/plugin/samples/integration/allocate_sample.py create mode 100644 src/backend/InvenTree/plugin/samples/integration/test_allocate_sample.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cc02cc7db..0e8bb83f05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. - [#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. +- [#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 diff --git a/docs/docs/plugins/develop.md b/docs/docs/plugins/develop.md index 329ced57d4..9875de9f99 100644 --- a/docs/docs/plugins/develop.md +++ b/docs/docs/plugins/develop.md @@ -127,6 +127,7 @@ Supported mixin classes are: | Mixin | Description | | --- | --- | | [ActionMixin](./mixins/action.md) | Run custom actions | +| [AllocateMixin](./mixins/allocate.md) | Customize automatic stock allocation | | [APICallMixin](./mixins/api.md) | Perform calls to external APIs | | [AppMixin](./mixins/app.md) | Integrate additional database tables | | [BarcodeMixin](./mixins/barcode.md) | Support custom barcode actions | diff --git a/docs/docs/plugins/mixins/allocate.md b/docs/docs/plugins/mixins/allocate.md new file mode 100644 index 0000000000..0018994442 --- /dev/null +++ b/docs/docs/plugins/mixins/allocate.md @@ -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: [] diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 8ba14dcd79..cd91f807ae 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -218,6 +218,7 @@ nav: - Unit Test: plugins/test.md - Plugin Mixins: - Action Mixin: plugins/mixins/action.md + - Allocate Mixin: plugins/mixins/allocate.md - API Mixin: plugins/mixins/api.md - App Mixin: plugins/mixins/app.md - Barcode Mixin: plugins/mixins/barcode.md diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index fc872a18f2..279c9fd88d 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -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. diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index 373b13eb86..d665022086 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -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 diff --git a/src/backend/InvenTree/plugin/base/integration/AllocateMixin.py b/src/backend/InvenTree/plugin/base/integration/AllocateMixin.py new file mode 100644 index 0000000000..d961c23d89 --- /dev/null +++ b/src/backend/InvenTree/plugin/base/integration/AllocateMixin.py @@ -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 diff --git a/src/backend/InvenTree/plugin/base/integration/test_mixins.py b/src/backend/InvenTree/plugin/base/integration/test_mixins.py index 4cf7e34714..dfe6dff239 100644 --- a/src/backend/InvenTree/plugin/base/integration/test_mixins.py +++ b/src/backend/InvenTree/plugin/base/integration/test_mixins.py @@ -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.""" diff --git a/src/backend/InvenTree/plugin/mixins/__init__.py b/src/backend/InvenTree/plugin/mixins/__init__.py index daa91a3475..5a799baa6f 100644 --- a/src/backend/InvenTree/plugin/mixins/__init__.py +++ b/src/backend/InvenTree/plugin/mixins/__init__.py @@ -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', diff --git a/src/backend/InvenTree/plugin/plugin.py b/src/backend/InvenTree/plugin/plugin.py index 55287dbae7..a32d62e2e7 100644 --- a/src/backend/InvenTree/plugin/plugin.py +++ b/src/backend/InvenTree/plugin/plugin.py @@ -61,6 +61,7 @@ class PluginMixinEnum(StringEnum): BASE = 'base' ACTION = 'action' + ALLOCATE = 'allocate' API_CALL = 'api_call' APP = 'app' BARCODE = 'barcode' diff --git a/src/backend/InvenTree/plugin/samples/integration/allocate_sample.py b/src/backend/InvenTree/plugin/samples/integration/allocate_sample.py new file mode 100644 index 0000000000..4656adc495 --- /dev/null +++ b/src/backend/InvenTree/plugin/samples/integration/allocate_sample.py @@ -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] diff --git a/src/backend/InvenTree/plugin/samples/integration/test_allocate_sample.py b/src/backend/InvenTree/plugin/samples/integration/test_allocate_sample.py new file mode 100644 index 0000000000..c0e92a222d --- /dev/null +++ b/src/backend/InvenTree/plugin/samples/integration/test_allocate_sample.py @@ -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)