From 60db8a924ec62acca557df3ad3e83056444e1e92 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 28 Oct 2026 10:42:44 +1100 Subject: [PATCH] Updates for AutoCreateBuildPlugin (#13118) --- .../plugins/builtin/auto_create_builds.md | 24 +++ .../builtin/events/auto_create_builds.py | 141 ++++++++++++++--- .../builtin/events/test_auto_create_builds.py | 143 ++++++++++++++++++ 3 files changed, 286 insertions(+), 22 deletions(-) create mode 100644 src/backend/InvenTree/plugin/builtin/events/test_auto_create_builds.py diff --git a/docs/docs/plugins/builtin/auto_create_builds.md b/docs/docs/plugins/builtin/auto_create_builds.md index 342c7b4cf9..400dba073a 100644 --- a/docs/docs/plugins/builtin/auto_create_builds.md +++ b/docs/docs/plugins/builtin/auto_create_builds.md @@ -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. diff --git a/src/backend/InvenTree/plugin/builtin/events/auto_create_builds.py b/src/backend/InvenTree/plugin/builtin/events/auto_create_builds.py index 4cd59f916a..d38295838b 100644 --- a/src/backend/InvenTree/plugin/builtin/events/auto_create_builds.py +++ b/src/backend/InvenTree/plugin/builtin/events/auto_create_builds.py @@ -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), + ) diff --git a/src/backend/InvenTree/plugin/builtin/events/test_auto_create_builds.py b/src/backend/InvenTree/plugin/builtin/events/test_auto_create_builds.py new file mode 100644 index 0000000000..92d2711f4f --- /dev/null +++ b/src/backend/InvenTree/plugin/builtin/events/test_auto_create_builds.py @@ -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)