mirror of
https://github.com/inventree/InvenTree.git
synced 2026-09-27 14:16:02 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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: []
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user