mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 05:05: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