diff --git a/docs/docs/plugins/builtin/auto_create_builds.md b/docs/docs/plugins/builtin/auto_create_builds.md new file mode 100644 index 0000000000..342c7b4cf9 --- /dev/null +++ b/docs/docs/plugins/builtin/auto_create_builds.md @@ -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. diff --git a/docs/docs/plugins/builtin/auto_issue.md b/docs/docs/plugins/builtin/auto_issue.md index 412c931467..8b3aba6f4e 100644 --- a/docs/docs/plugins/builtin/auto_issue.md +++ b/docs/docs/plugins/builtin/auto_issue.md @@ -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 diff --git a/docs/docs/plugins/builtin/index.md b/docs/docs/plugins/builtin/index.md index d2e38ffef3..210d7abe5a 100644 --- a/docs/docs/plugins/builtin/index.md +++ b/docs/docs/plugins/builtin/index.md @@ -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 | diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 8db8a09426..f0270526af 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -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 diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 0dea53508b..e7a9491079 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index 675bdd76b6..9d97998dde 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -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 diff --git a/src/backend/InvenTree/build/tasks.py b/src/backend/InvenTree/build/tasks.py index 38a2198cc2..d06c9804ee 100644 --- a/src/backend/InvenTree/build/tasks.py +++ b/src/backend/InvenTree/build/tasks.py @@ -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 = [] diff --git a/src/backend/InvenTree/build/test_api.py b/src/backend/InvenTree/build/test_api.py index a29d64143a..7a77cbb551 100644 --- a/src/backend/InvenTree/build/test_api.py +++ b/src/backend/InvenTree/build/test_api.py @@ -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. diff --git a/src/backend/InvenTree/plugin/builtin/events/auto_create_builds.py b/src/backend/InvenTree/plugin/builtin/events/auto_create_builds.py new file mode 100644 index 0000000000..55469164b6 --- /dev/null +++ b/src/backend/InvenTree/plugin/builtin/events/auto_create_builds.py @@ -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, + )