mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-11-04 15:15:42 +00:00 
			
		
		
		
	[Plugin] Auto-create builds (#9574)
* Remove existing "create child builds" functionality - Remove API fields - Remove background task definition * Basic plugin structure * Bump API version * Bump API version * Bug fix * working on plugin event handling * Add new stub method * Implement functionality * Fix conflicts in api_version * Docs * Fix docs * Fix event type
This commit is contained in:
		
							
								
								
									
										21
									
								
								docs/docs/plugins/builtin/auto_create_builds.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								docs/docs/plugins/builtin/auto_create_builds.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
			
		||||
---
 | 
			
		||||
title: Auto Create Child Builds Plugin
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## Auto Create Child Builds Plugin
 | 
			
		||||
 | 
			
		||||
The **Auto Create Child Builds Plugin** provides a mechanism to automatically create build orders for sub-assemblies when a higher level build order is issued.
 | 
			
		||||
 | 
			
		||||
### Activation
 | 
			
		||||
 | 
			
		||||
This plugin is an *optional* plugin, and must be enabled in the InvenTree settings.
 | 
			
		||||
 | 
			
		||||
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.
 | 
			
		||||
 | 
			
		||||
## 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.
 | 
			
		||||
 | 
			
		||||
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.
 | 
			
		||||
@@ -10,7 +10,7 @@ The **Auto Issue Orders Plugin** provides a mechanism to automatically issue pen
 | 
			
		||||
 | 
			
		||||
This plugin is an *optional* plugin, and must be enabled in the InvenTree settings.
 | 
			
		||||
 | 
			
		||||
Additionally, the "Enable Schedule Integration" setting must be enabled in the InvenTree plugin settings. This is required to allow plugins to run scheduled tasks.
 | 
			
		||||
Additionally, the {{ globalsetting("ENABLE_PLUGINS_SCHEDULE", short=True) }} setting must be enabled in the InvenTree plugin settings. This is required to allow plugins to run scheduled tasks.
 | 
			
		||||
 | 
			
		||||
### Plugin Settings
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -14,6 +14,7 @@ The following builtin plugins are available in InvenTree:
 | 
			
		||||
 | 
			
		||||
| Plugin Name | Description | Mandatory |
 | 
			
		||||
| ----------- | ----------- | --------- |
 | 
			
		||||
| [Auto Create Child Builds](./auto_create_builds.md) | Automatically create child build orders for sub-assemblies | No |
 | 
			
		||||
| [Auto Issue Orders](./auto_issue.md) | Automatically issue pending orders when target date is reached | No |
 | 
			
		||||
| [BOM Exporter](./bom_exporter.md) | Custom [exporter](../mixins/export.md) for BOM data | Yes |
 | 
			
		||||
| [Currency Exchange](./currency_exchange.md) | Currency exchange rate plugin | Yes |
 | 
			
		||||
 
 | 
			
		||||
@@ -238,6 +238,7 @@ nav:
 | 
			
		||||
      - Label Printer: plugins/machines/label_printer.md
 | 
			
		||||
    - Builtin Plugins:
 | 
			
		||||
      - Builtin Plugins: plugins/builtin/index.md
 | 
			
		||||
      - Auto Create Builds: plugins/builtin/auto_create_builds.md
 | 
			
		||||
      - Auto Issue: plugins/builtin/auto_issue.md
 | 
			
		||||
      - BOM Exporter: plugins/builtin/bom_exporter.md
 | 
			
		||||
      - Currency Exchange: plugins/builtin/currency_exchange.md
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,16 @@
 | 
			
		||||
"""InvenTree API version information."""
 | 
			
		||||
 | 
			
		||||
# InvenTree API version
 | 
			
		||||
INVENTREE_API_VERSION = 348
 | 
			
		||||
INVENTREE_API_VERSION = 349
 | 
			
		||||
 | 
			
		||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
INVENTREE_API_TEXT = """
 | 
			
		||||
v348 -> 2025-04-22 : https://github.com/inventree/InvenTree/pull/9312
 | 
			
		||||
v349 -> 2025-06-13 : https://github.com/inventree/InvenTree/pull/9574
 | 
			
		||||
    - Remove the 'create_child_builds' flag from the BuildOrder creation API endpoint
 | 
			
		||||
 | 
			
		||||
v348 -> 2025-06-12 : https://github.com/inventree/InvenTree/pull/9312
 | 
			
		||||
    - Adds "external" flag for BuildOrder
 | 
			
		||||
    - Adds link between PurchaseOrderLineItem and BuildOrder
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -99,8 +99,6 @@ class BuildSerializer(
 | 
			
		||||
            'responsible_detail',
 | 
			
		||||
            'priority',
 | 
			
		||||
            'level',
 | 
			
		||||
            # Additional fields used only for build order creation
 | 
			
		||||
            'create_child_builds',
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
        read_only_fields = [
 | 
			
		||||
@@ -149,14 +147,6 @@ class BuildSerializer(
 | 
			
		||||
        source='project_code', many=False, read_only=True, allow_null=True
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    create_child_builds = serializers.BooleanField(
 | 
			
		||||
        default=False,
 | 
			
		||||
        required=False,
 | 
			
		||||
        write_only=True,
 | 
			
		||||
        label=_('Create Child Builds'),
 | 
			
		||||
        help_text=_('Automatically generate child build orders'),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def annotate_queryset(queryset):
 | 
			
		||||
        """Add custom annotations to the BuildSerializer queryset, performing database queries as efficiently as possible.
 | 
			
		||||
@@ -181,23 +171,16 @@ class BuildSerializer(
 | 
			
		||||
    def __init__(self, *args, **kwargs):
 | 
			
		||||
        """Determine if extra serializer fields are required."""
 | 
			
		||||
        part_detail = kwargs.pop('part_detail', True)
 | 
			
		||||
        create = kwargs.pop('create', False)
 | 
			
		||||
        kwargs.pop('create', False)
 | 
			
		||||
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
        if isGeneratingSchema():
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        if not create:
 | 
			
		||||
            self.fields.pop('create_child_builds', None)
 | 
			
		||||
 | 
			
		||||
        if not part_detail:
 | 
			
		||||
            self.fields.pop('part_detail', None)
 | 
			
		||||
 | 
			
		||||
    def skip_create_fields(self):
 | 
			
		||||
        """Return a list of fields to skip during model creation."""
 | 
			
		||||
        return ['create_child_builds']
 | 
			
		||||
 | 
			
		||||
    def validate_reference(self, reference):
 | 
			
		||||
        """Custom validation for the Build reference field."""
 | 
			
		||||
        # Ensure the reference matches the required pattern
 | 
			
		||||
@@ -205,21 +188,6 @@ class BuildSerializer(
 | 
			
		||||
 | 
			
		||||
        return reference
 | 
			
		||||
 | 
			
		||||
    @transaction.atomic
 | 
			
		||||
    def create(self, validated_data):
 | 
			
		||||
        """Save the Build object."""
 | 
			
		||||
        build_order = super().create(validated_data)
 | 
			
		||||
 | 
			
		||||
        create_child_builds = self.validated_data.pop('create_child_builds', False)
 | 
			
		||||
 | 
			
		||||
        if create_child_builds:
 | 
			
		||||
            # Pass child build creation off to the background thread
 | 
			
		||||
            InvenTree.tasks.offload_task(
 | 
			
		||||
                build.tasks.create_child_builds, build_order.pk, group='build'
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        return build_order
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BuildOutputSerializer(serializers.Serializer):
 | 
			
		||||
    """Serializer for a "BuildOutput".
 | 
			
		||||
@@ -1137,7 +1105,6 @@ class BuildAutoAllocationSerializer(serializers.Serializer):
 | 
			
		||||
 | 
			
		||||
    def save(self):
 | 
			
		||||
        """Perform the auto-allocation step."""
 | 
			
		||||
        import build.tasks
 | 
			
		||||
        import InvenTree.tasks
 | 
			
		||||
 | 
			
		||||
        data = self.validated_data
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,9 @@
 | 
			
		||||
"""Background task definitions for the BuildOrder app."""
 | 
			
		||||
 | 
			
		||||
import random
 | 
			
		||||
import time
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
 | 
			
		||||
from django.contrib.auth.models import User
 | 
			
		||||
from django.db import transaction
 | 
			
		||||
from django.template.loader import render_to_string
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
 | 
			
		||||
@@ -199,59 +196,6 @@ def check_build_stock(build: build_models.Build):
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def create_child_builds(build_id: int) -> None:
 | 
			
		||||
    """Create child build orders for a given parent build.
 | 
			
		||||
 | 
			
		||||
    - Will create a build order for each assembly part in the BOM
 | 
			
		||||
    - Runs recursively, also creating child builds for each sub-assembly part
 | 
			
		||||
    """
 | 
			
		||||
    try:
 | 
			
		||||
        build_order = build_models.Build.objects.get(pk=build_id)
 | 
			
		||||
    except (build_models.Build.DoesNotExist, ValueError):
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    assembly_items = build_order.part.get_bom_items().filter(sub_part__assembly=True)
 | 
			
		||||
 | 
			
		||||
    # Random delay, to reduce likelihood of race conditions from multiple build orders being created simultaneously
 | 
			
		||||
    time.sleep(random.random())
 | 
			
		||||
 | 
			
		||||
    with transaction.atomic():
 | 
			
		||||
        # Atomic transaction to ensure that all child build orders are created together, or not at all
 | 
			
		||||
        # This is critical to prevent duplicate child build orders being created (e.g. if the task is re-run)
 | 
			
		||||
 | 
			
		||||
        sub_build_ids = []
 | 
			
		||||
 | 
			
		||||
        for item in assembly_items:
 | 
			
		||||
            quantity = item.quantity * build_order.quantity
 | 
			
		||||
 | 
			
		||||
            # Check if the child build order has already been created
 | 
			
		||||
            if build_models.Build.objects.filter(
 | 
			
		||||
                part=item.sub_part,
 | 
			
		||||
                parent=build_order,
 | 
			
		||||
                quantity=quantity,
 | 
			
		||||
                status__in=BuildStatusGroups.ACTIVE_CODES,
 | 
			
		||||
            ).exists():
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            sub_order = build_models.Build.objects.create(
 | 
			
		||||
                part=item.sub_part,
 | 
			
		||||
                quantity=quantity,
 | 
			
		||||
                title=build_order.title,
 | 
			
		||||
                batch=build_order.batch,
 | 
			
		||||
                parent=build_order,
 | 
			
		||||
                target_date=build_order.target_date,
 | 
			
		||||
                sales_order=build_order.sales_order,
 | 
			
		||||
                issued_by=build_order.issued_by,
 | 
			
		||||
                responsible=build_order.responsible,
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
            sub_build_ids.append(sub_order.pk)
 | 
			
		||||
 | 
			
		||||
        for pk in sub_build_ids:
 | 
			
		||||
            # Offload the child build order creation to the background task queue
 | 
			
		||||
            InvenTree.tasks.offload_task(create_child_builds, pk, group='build')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def notify_overdue_build_order(bo: build_models.Build):
 | 
			
		||||
    """Notify appropriate users that a Build has just become 'overdue'."""
 | 
			
		||||
    targets = []
 | 
			
		||||
 
 | 
			
		||||
@@ -516,35 +516,6 @@ class BuildTest(BuildAPITest):
 | 
			
		||||
 | 
			
		||||
        self.assertEqual(bo.children.count(), 0)
 | 
			
		||||
 | 
			
		||||
        # Create a build order for Part A, and auto-create child builds
 | 
			
		||||
        response = self.post(
 | 
			
		||||
            url,
 | 
			
		||||
            {
 | 
			
		||||
                'reference': 'BO-9875',
 | 
			
		||||
                'part': part_a.pk,
 | 
			
		||||
                'quantity': 15,
 | 
			
		||||
                'title': 'A build - with childs',
 | 
			
		||||
                'create_child_builds': True,
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # An addition 1 + 2 builds should have been created
 | 
			
		||||
        self.assertEqual(n + 4, Build.objects.count())
 | 
			
		||||
 | 
			
		||||
        bo = Build.objects.get(pk=response.data['pk'])
 | 
			
		||||
 | 
			
		||||
        # One build has a direct child
 | 
			
		||||
        self.assertEqual(bo.children.count(), 1)
 | 
			
		||||
        child = bo.children.first()
 | 
			
		||||
        self.assertEqual(child.part.pk, part_b.pk)
 | 
			
		||||
        self.assertEqual(child.quantity, 75)
 | 
			
		||||
 | 
			
		||||
        # And there should be a second-level child build too
 | 
			
		||||
        self.assertEqual(child.children.count(), 1)
 | 
			
		||||
        child = child.children.first()
 | 
			
		||||
        self.assertEqual(child.part.pk, part_c.pk)
 | 
			
		||||
        self.assertEqual(child.quantity, 7 * 5 * 15)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BuildAllocationTest(BuildAPITest):
 | 
			
		||||
    """Unit tests for allocation of stock items against a build order.
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,103 @@
 | 
			
		||||
"""Plugin to automatically create build orders for assemblies."""
 | 
			
		||||
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
 | 
			
		||||
import structlog
 | 
			
		||||
 | 
			
		||||
from build.events import BuildEvents
 | 
			
		||||
from build.models import Build
 | 
			
		||||
from build.status_codes import BuildStatus
 | 
			
		||||
from plugin import InvenTreePlugin
 | 
			
		||||
from plugin.mixins import EventMixin
 | 
			
		||||
 | 
			
		||||
logger = structlog.get_logger('inventree')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AutoCreateBuildsPlugin(EventMixin, InvenTreePlugin):
 | 
			
		||||
    """Plugin which automatically creates build orders for assemblies.
 | 
			
		||||
 | 
			
		||||
    This plugin can be used to automatically create new build orders based on certain events:
 | 
			
		||||
 | 
			
		||||
    - 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.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    NAME = _('Auto Create Builds')
 | 
			
		||||
    SLUG = 'autocreatebuilds'
 | 
			
		||||
    AUTHOR = _('InvenTree contributors')
 | 
			
		||||
    DESCRIPTION = _('Automatically create build orders for assemblies')
 | 
			
		||||
    VERSION = '1.0.0'
 | 
			
		||||
 | 
			
		||||
    def wants_process_event(self, event) -> bool:
 | 
			
		||||
        """Return whether given event should be processed or not."""
 | 
			
		||||
        return event in [BuildEvents.ISSUED]
 | 
			
		||||
 | 
			
		||||
    def process_event(self, event, *args, **kwargs):
 | 
			
		||||
        """Process the triggered event."""
 | 
			
		||||
        build_id = kwargs.get('id')
 | 
			
		||||
 | 
			
		||||
        if not build_id:
 | 
			
		||||
            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)
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        if event == BuildEvents.ISSUED:
 | 
			
		||||
            self.process_build(build)
 | 
			
		||||
 | 
			
		||||
    def process_build(self, build: Build):
 | 
			
		||||
        """Process a build order when it is issued."""
 | 
			
		||||
        # Iterate through all sub-assemblies for the build order
 | 
			
		||||
        for bom_item in build.part.get_bom_items().filter(
 | 
			
		||||
            consumable=False, sub_part__assembly=True
 | 
			
		||||
        ):
 | 
			
		||||
            subassembly = bom_item.sub_part
 | 
			
		||||
 | 
			
		||||
            with_variants = bom_item.allow_variants
 | 
			
		||||
 | 
			
		||||
            # Quantity required for this particular build order
 | 
			
		||||
            build_quantity = bom_item.get_required_quantity(build.quantity)
 | 
			
		||||
 | 
			
		||||
            # Total existing stock for the sub-assembly
 | 
			
		||||
            stock_quantity = subassembly.get_stock_count(include_variants=with_variants)
 | 
			
		||||
 | 
			
		||||
            # How many of the sub-assembly are already allocated?
 | 
			
		||||
            allocated_quantity = subassembly.allocation_count(
 | 
			
		||||
                include_variants=with_variants
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
            # Quantity of item already scheduled for production?
 | 
			
		||||
            in_production_quantity = subassembly.quantity_being_built
 | 
			
		||||
 | 
			
		||||
            # Determine if there is sufficient stock for the sub-assembly
 | 
			
		||||
            required_quantity = (
 | 
			
		||||
                Decimal(build_quantity)
 | 
			
		||||
                + Decimal(subassembly.minimum_stock)
 | 
			
		||||
                + Decimal(allocated_quantity)
 | 
			
		||||
                - Decimal(stock_quantity)
 | 
			
		||||
                - Decimal(in_production_quantity)
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
            if required_quantity <= 0:
 | 
			
		||||
                # No need to build this sub-assembly
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            # Generate a new build order for the sub-assembly
 | 
			
		||||
            Build.objects.create(
 | 
			
		||||
                part=subassembly,
 | 
			
		||||
                parent=build,
 | 
			
		||||
                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,
 | 
			
		||||
            )
 | 
			
		||||
		Reference in New Issue
	
	Block a user