Updates for AutoCreateBuildPlugin (#13118)

This commit is contained in:
Oliver authored and GitHub committed 2026-10-28 10:42:44 +11:00
1 parent 3e55d413ec
commit 60db8a924e
3 files changed
+286 -22

No files matched your search

@@ -12,6 +12,14 @@ This plugin is an *optional* plugin, and must be enabled in the InvenTree settin
Additionally, the {{ globalsetting("ENABLE_PLUGINS_EVENTS", short=True) }} setting must be enabled in the InvenTree plugin settings. This is required to allow plugins to respond to events in the InvenTree system.
### Plugin Settings
| Setting | Description | Default |
| --- | --- | --- |
| Cascade Depth | How many levels of the sub-assembly tree to generate child build orders for | 1 |
With the default depth of `1`, child build orders are only created for the direct sub-assemblies of the issued build order. A larger value cascades further down the BOM tree - each additional level is processed as a separate background task.
## Usage
When this plugin is enabled, any time a build order is issued, the plugin will automatically create build orders for any sub-assemblies that are required by the issued build order.
@@ -19,3 +27,19 @@ When this plugin is enabled, any time a build order is issued, the plugin will a
This process is performed in the background, and does not require any user interaction.
Any new build orders that are created by this plugin will be marked as `PENDING`, and will require review and approval by the user before they can be issued.
### Quantity Calculation
A child build order is only created when there is insufficient stock of the sub-assembly. The quantity of the new build order is calculated as:
```
required = (BOM requirement) + (minimum stock) + (allocated stock) - (stock on hand) - (quantity in production)
```
The result is rounded up to the nearest whole number.
### Duplicate Prevention
A child build order is not created for a sub-assembly if the build order already has a child build order for that part. This means that issuing a build order multiple times (e.g. after placing it on hold) will not create duplicate child build orders.
If a sub-assembly cannot be built (for example, the part is inactive and {{ globalsetting("BUILDORDER_REQUIRE_ACTIVE_PART", short=True) }} is enabled), it is skipped and a warning is logged.
@@ -1,7 +1,12 @@
"""Plugin to automatically create build orders for assemblies."""
import functools
import math
from decimal import Decimal
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import transaction
from django.utils.translation import gettext_lazy as _
import structlog
@@ -9,14 +14,16 @@ import structlog
import part.models
from build.events import BuildEvents
from build.models import Build
from build.status_codes import BuildStatus
from build.status_codes import BuildStatus, BuildStatusGroups
from InvenTree.tasks import offload_task
from plugin import InvenTreePlugin
from plugin.mixins import EventMixin
from plugin.mixins import EventMixin, SettingsMixin
from plugin.registry import call_plugin_function
logger = structlog.get_logger('inventree')
class AutoCreateBuildsPlugin(EventMixin, InvenTreePlugin):
class AutoCreateBuildsPlugin(EventMixin, SettingsMixin, InvenTreePlugin):
"""Plugin which automatically creates build orders for assemblies.
This plugin can be used to automatically create new build orders based on certain events:
@@ -24,13 +31,36 @@ class AutoCreateBuildsPlugin(EventMixin, InvenTreePlugin):
- When a build order is issued, automatically generate child build orders for sub-assemblies
Build orders are only created for parts which are have insufficient stock to fulfill the order.
Child build orders can be cascaded further down the BOM tree (see the CASCADE_DEPTH setting).
Each additional level is processed as a separate background task.
"""
NAME = _('Auto Create Builds')
SLUG = 'autocreatebuilds'
AUTHOR = _('InvenTree contributors')
DESCRIPTION = _('Automatically create build orders for assemblies')
VERSION = '1.1.0'
VERSION = '1.2.0'
SETTINGS = {
'CASCADE_DEPTH': {
'name': _('Cascade Depth'),
'description': _(
'How many levels of the sub-assembly tree to generate child build orders for'
),
'validator': [int, MinValueValidator(1)],
'default': 1,
}
}
def get_cascade_depth(self) -> int:
"""Return the configured cascade depth (minimum 1)."""
try:
depth = int(self.get_setting('CASCADE_DEPTH', backup_value=1))
except (TypeError, ValueError):
depth = 1
return max(depth, 1)
def wants_process_event(self, event) -> bool:
"""Return whether given event should be processed or not."""
@@ -44,17 +74,61 @@ class AutoCreateBuildsPlugin(EventMixin, InvenTreePlugin):
logger.error('No build ID provided for event', event=event)
return
try:
build = Build.objects.get(pk=build_id)
except (ValueError, Build.DoesNotExist):
logger.error('Invalid build ID provided', build_id=build_id)
if event == BuildEvents.ISSUED:
self.create_child_builds(build_id, self.get_cascade_depth())
def create_child_builds(self, build_id: int, depth: int = 1):
"""Generate child build orders for the specified build order.
This method is idempotent - it can safely be run multiple times
(or concurrently by multiple workers) for the same build order.
Arguments:
build_id: Primary key of the parent build order
depth: Number of levels of the sub-assembly tree (including this one) to process
"""
if depth < 1:
return
if event == BuildEvents.ISSUED:
with transaction.atomic():
# Lock the parent build, so that concurrent workers processing
# the same build order are serialized against each other
try:
build = Build.objects.select_for_update().get(pk=build_id)
except (ValueError, Build.DoesNotExist):
logger.error('Invalid build ID provided', build_id=build_id)
return
if build.status not in BuildStatusGroups.ACTIVE_CODES:
# Do not generate child builds for a cancelled or completed build
return
self.process_build(build)
if depth > 1:
# Cascade down to the next level of the sub-assembly tree
# The tasks are only registered once the new child builds are committed
for child_id in build.children.filter(
status__in=BuildStatusGroups.ACTIVE_CODES
).values_list('pk', flat=True):
transaction.on_commit(
functools.partial(
offload_task,
call_plugin_function,
self.slug,
'create_child_builds',
child_id,
depth - 1,
group='plugin',
raise_error=False,
)
)
def process_build(self, build: Build):
"""Process a build order when it is issued."""
"""Create child build orders for any sub-assemblies which have insufficient stock.
Note: The caller is expected to hold a lock on the provided build order.
"""
# Iterate through all sub-assemblies for the build order
for bom_item in build.part.get_bom_items().filter(
part.models.BomItem.consumable_filter(consumable=False),
@@ -64,6 +138,18 @@ class AutoCreateBuildsPlugin(EventMixin, InvenTreePlugin):
with_variants = bom_item.allow_variants
# Skip if a child build already exists for this sub-assembly
# (e.g. the build was previously issued, or this task is being re-run)
if with_variants:
children = build.children.filter(
part__in=subassembly.get_descendants(include_self=True)
)
else:
children = build.children.filter(part=subassembly)
if children.exists():
continue
# Quantity required for this particular build order
build_quantity = bom_item.get_required_quantity(build.quantity)
@@ -92,15 +178,26 @@ class AutoCreateBuildsPlugin(EventMixin, InvenTreePlugin):
continue
# Generate a new build order for the sub-assembly
Build.objects.create(
part=subassembly,
parent=build,
title=f'Generated child build for {build.reference}',
project_code=build.project_code,
quantity=required_quantity,
responsible=build.responsible,
sales_order=build.sales_order,
start_date=build.start_date,
status=BuildStatus.PENDING,
target_date=build.target_date,
)
# Build quantity is an integer field, so round up to avoid under-production
try:
with transaction.atomic():
Build.objects.create(
part=subassembly,
parent=build,
title=f'Generated child build for {build.reference}',
project_code=build.project_code,
quantity=math.ceil(required_quantity),
responsible=build.responsible,
sales_order=build.sales_order,
start_date=build.start_date,
status=BuildStatus.PENDING,
target_date=build.target_date,
)
except ValidationError as e:
# e.g. the sub-assembly is inactive, or has an unvalidated BOM
logger.warning(
'Could not create child build order',
build=build.reference,
part=subassembly.pk,
error=str(e),
)
@@ -0,0 +1,143 @@
"""Unit tests for the AutoCreateBuildsPlugin."""
from decimal import Decimal
from build.models import Build
from build.status_codes import BuildStatus
from common.settings import set_global_setting
from InvenTree.unit_test import InvenTreeTestCase
from part.models import BomItem, Part
from plugin import registry
from stock.models import StockItem
class AutoCreateBuildsTest(InvenTreeTestCase):
"""Tests for automatically creating child build orders."""
@classmethod
def setUpTestData(cls):
"""Create a multi-level assembly tree.
top -> sub_1 (x0.5) -> sub_2 (x2) -> sub_3 (x1)
"""
super().setUpTestData()
cls.top = Part.objects.create(name='Top', description='Top', assembly=True)
cls.sub_1 = Part.objects.create(
name='Sub 1', description='Sub 1', assembly=True, component=True
)
cls.sub_2 = Part.objects.create(
name='Sub 2', description='Sub 2', assembly=True, component=True
)
cls.sub_3 = Part.objects.create(
name='Sub 3', description='Sub 3', assembly=True, component=True
)
BomItem.objects.create(part=cls.top, sub_part=cls.sub_1, quantity=0.5)
BomItem.objects.create(part=cls.sub_1, sub_part=cls.sub_2, quantity=2)
BomItem.objects.create(part=cls.sub_2, sub_part=cls.sub_3, quantity=1)
def setUp(self):
"""Activate the plugin and create a top-level build order."""
super().setUp()
registry.set_plugin_state('autocreatebuilds', True)
self.plugin = registry.get_plugin('autocreatebuilds')
self.build = Build.objects.create(
part=self.top, quantity=3, status=BuildStatus.PRODUCTION, title='Top build'
)
def set_depth(self, depth: int):
"""Set the cascade depth for the plugin."""
self.plugin.set_setting('CASCADE_DEPTH', depth)
def run_plugin(self):
"""Run the plugin against the top-level build, executing any cascaded tasks."""
with self.captureOnCommitCallbacks(execute=True):
self.plugin.create_child_builds(
self.build.pk, self.plugin.get_cascade_depth()
)
def test_default_depth(self):
"""With the default depth, only direct children are created."""
self.assertEqual(self.plugin.get_cascade_depth(), 1)
self.run_plugin()
children = self.build.children.all()
self.assertEqual(children.count(), 1)
child = children.first()
self.assertEqual(child.part, self.sub_1)
self.assertEqual(child.status, BuildStatus.PENDING)
# 3 x 0.5 = 1.5, rounded up
self.assertEqual(child.quantity, 2)
self.assertEqual(child.children.count(), 0)
self.assertEqual(Build.objects.count(), 2)
def test_cascade(self):
"""Child builds are cascaded down to the configured depth."""
self.set_depth(2)
self.run_plugin()
child = self.build.children.get()
grandchild = child.children.get()
self.assertEqual(grandchild.part, self.sub_2)
self.assertEqual(grandchild.quantity, 4)
self.assertEqual(grandchild.children.count(), 0)
# Increasing the depth extends the existing tree
self.set_depth(5)
self.run_plugin()
great_grandchild = grandchild.children.get()
self.assertEqual(great_grandchild.part, self.sub_3)
self.assertEqual(great_grandchild.quantity, 4)
self.assertEqual(Build.objects.count(), 4)
def test_idempotent(self):
"""Running the plugin multiple times does not create duplicate builds."""
self.set_depth(3)
for _ in range(3):
self.run_plugin()
self.assertEqual(Build.objects.count(), 4)
for part in [self.sub_1, self.sub_2, self.sub_3]:
self.assertEqual(Build.objects.filter(part=part).count(), 1)
def test_sufficient_stock(self):
"""No child build is created if there is sufficient stock."""
StockItem.objects.create(part=self.sub_1, quantity=Decimal(10))
self.set_depth(3)
self.run_plugin()
self.assertEqual(self.build.children.count(), 0)
self.assertEqual(Build.objects.count(), 1)
def test_inactive_build(self):
"""No child builds are created for an inactive build order."""
self.build.status = BuildStatus.CANCELLED
self.build.save()
self.run_plugin()
self.assertEqual(self.build.children.count(), 0)
def test_validation_error(self):
"""A sub-assembly which fails validation is skipped."""
set_global_setting('BUILDORDER_REQUIRE_ACTIVE_PART', True)
self.sub_1.active = False
self.sub_1.save()
self.run_plugin()
self.assertEqual(self.build.children.count(), 0)