mirror of
https://github.com/inventree/InvenTree.git
synced 2026-11-10 02:47:49 +00:00
Updates for AutoCreateBuildPlugin (#13118)
This commit is contained in:
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)
|
||||
Reference in new issue
Block a user