2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-07-04 06:00:38 +00:00

Check build complete (#12289)

* Display non-field errors in table field

* Refactor validation for build output completion

- Run validation BEFORE trying to complete (serializer validation)
- Refactor and simplify common code

* Adjust unit tests

* Fix typing

* Remove debug
This commit is contained in:
Oliver
2026-07-01 16:47:33 +10:00
committed by GitHub
parent 09f85aeae9
commit 609e5ee105
7 changed files with 278 additions and 91 deletions
+63 -43
View File
@@ -37,10 +37,7 @@ from build.validators import (
validate_build_order_reference, validate_build_order_reference,
) )
from common.models import ProjectCode from common.models import ProjectCode
from common.settings import ( from common.settings import get_global_setting
get_global_setting,
prevent_build_output_complete_on_incompleted_tests,
)
from generic.enums import StringEnum from generic.enums import StringEnum
from generic.states import StateTransitionMixin, StatusCodeMixin from generic.states import StateTransitionMixin, StatusCodeMixin
from plugin.events import trigger_event from plugin.events import trigger_event
@@ -1093,6 +1090,61 @@ class Build(
}, },
) )
def can_complete_output(
self,
output: stock.models.StockItem,
quantity: Optional[decimal.Decimal] = None,
required_tests=None,
) -> bool:
"""Determine if the given build output can be completed.
Arguments:
output: The StockItem instance (build output) to check
quantity: The quantity to complete (defaults to entire output quantity)
required_tests: Optional list of required tests to check against (defaults to the part's required tests)
Returns:
True if the build output can be completed, False otherwise
Raises:
ValidationError: If the build output cannot be completed, with an appropriate message
"""
prevent_incomplete = get_global_setting(
'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS'
)
if prevent_incomplete and not output.passedAllRequiredTests(
required_tests=required_tests
):
raise ValidationError(_('Build output has not passed all required tests'))
# Ensure that none of the allocated items are themselves still "in production"
allocated_items = output.items_to_install.all().filter(
stock_item__is_building=True
)
if allocated_items.exists():
raise ValidationError(_('Allocated stock items are still in production'))
if quantity is not None and quantity != output.quantity:
# Cannot split a build output with allocated items
if output.items_to_install.exists():
raise ValidationError({
'quantity': _(
'Cannot partially complete a build output with allocated items'
)
})
if quantity <= 0:
raise ValidationError({
'quantity': _('Quantity must be greater than zero')
})
if quantity > output.quantity:
raise ValidationError({
'quantity': _('Quantity cannot be greater than the output quantity')
})
@transaction.atomic @transaction.atomic
def complete_build_output( def complete_build_output(
self, self,
@@ -1118,52 +1170,20 @@ class Build(
notes = kwargs.get('notes', '') notes = kwargs.get('notes', '')
required_tests = kwargs.get('required_tests', output.part.getRequiredTests()) required_tests = kwargs.get('required_tests', output.part.getRequiredTests())
prevent_on_incomplete = kwargs.get(
'prevent_on_incomplete', self.can_complete_output(
prevent_build_output_complete_on_incompleted_tests(), output, quantity=quantity, required_tests=required_tests
) )
if prevent_on_incomplete and not output.passedAllRequiredTests(
required_tests=required_tests
):
msg = _('Build output has not passed all required tests')
if serial := output.serial:
msg = _(f'Build output {serial} has not passed all required tests')
raise ValidationError(msg)
# List the allocated BuildItem objects for the given output
allocated_items = output.items_to_install.all()
# Ensure that none of the allocated items are themselves still "in production"
for build_item in allocated_items:
if build_item.stock_item.is_building:
raise ValidationError(
_('Allocated stock items are still in production')
)
# If a partial quantity is provided, split the stock output # If a partial quantity is provided, split the stock output
if quantity is not None and quantity != output.quantity: if quantity is not None and quantity != output.quantity:
# Cannot split a build output with allocated items
if allocated_items.count() > 0:
raise ValidationError(
_('Cannot partially complete a build output with allocated items')
)
if quantity <= 0:
raise ValidationError({
'quantity': _('Quantity must be greater than zero')
})
if quantity > output.quantity:
raise ValidationError({
'quantity': _('Quantity cannot be greater than the output quantity')
})
# Split the stock item # Split the stock item
output = output.splitStock(quantity, user=user, allow_production=True) output = output.splitStock(quantity, user=user, allow_production=True)
allocated_items = output.items_to_install.all().select_related(
'stock_item', 'stock_item__part'
)
for build_item in allocated_items: for build_item in allocated_items:
# Complete the allocation of stock for that item # Complete the allocation of stock for that item
build_item.complete_allocation(user=user) build_item.complete_allocation(user=user)
+18 -26
View File
@@ -1,6 +1,8 @@
"""JSON serializers for Build API.""" """JSON serializers for Build API."""
from collections.abc import Callable
from decimal import Decimal from decimal import Decimal
from typing import Optional
from django.core.exceptions import ValidationError as DjangoValidationError from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import models, transaction from django.db import models, transaction
@@ -22,7 +24,6 @@ from rest_framework import serializers
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
import common.filters import common.filters
import common.settings
import company.serializers import company.serializers
import InvenTree.helpers import InvenTree.helpers
import part.filters import part.filters
@@ -52,6 +53,7 @@ from users.serializers import OwnerSerializer, UserSerializer
from .models import Build, BuildItem, BuildLine from .models import Build, BuildItem, BuildLine
from .status_codes import BuildStatus from .status_codes import BuildStatus
from .validators import check_build_output
class BuildSerializer( class BuildSerializer(
@@ -260,11 +262,19 @@ class BuildOutputSerializer(serializers.Serializer):
class BuildOutputQuantitySerializer(BuildOutputSerializer): class BuildOutputQuantitySerializer(BuildOutputSerializer):
"""Build output with quantity field.""" """Build output with quantity field."""
# Optional callable to validate the output field, if required
output_validator: Optional[Callable] = None
class Meta: class Meta:
"""Serializer metaclass.""" """Serializer metaclass."""
fields = [*BuildOutputSerializer.Meta.fields, 'quantity'] fields = [*BuildOutputSerializer.Meta.fields, 'quantity']
def __init__(self, *args, **kwargs):
"""Initialize the serializer."""
self.output_validator = kwargs.pop('output_validator', None)
super().__init__(*args, **kwargs)
quantity = serializers.DecimalField( quantity = serializers.DecimalField(
max_digits=15, max_digits=15,
decimal_places=5, decimal_places=5,
@@ -292,6 +302,10 @@ class BuildOutputQuantitySerializer(BuildOutputSerializer):
'quantity': _('Quantity cannot be greater than the output quantity') 'quantity': _('Quantity cannot be greater than the output quantity')
}) })
if self.output_validator:
# Call the parent serializer's output validator, if provided
self.output_validator(output, quantity=quantity)
return data return data
@@ -527,7 +541,9 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
'notes', 'notes',
] ]
outputs = BuildOutputQuantitySerializer(many=True, required=True) outputs = BuildOutputQuantitySerializer(
many=True, required=True, output_validator=check_build_output
)
location = serializers.PrimaryKeyRelatedField( location = serializers.PrimaryKeyRelatedField(
queryset=StockLocation.objects.all(), queryset=StockLocation.objects.all(),
@@ -554,30 +570,6 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
outputs = data.get('outputs', []) outputs = data.get('outputs', [])
if common.settings.prevent_build_output_complete_on_incompleted_tests():
errors = []
for output in outputs:
stock_item = output['output']
if (
stock_item.hasRequiredTests()
and not stock_item.passedAllRequiredTests()
):
serial = stock_item.serial
if serial:
errors.append(
_(
f'Build output {serial} has not passed all required tests'
)
)
else:
errors.append(
_('Build output has not passed all required tests')
)
if errors:
raise ValidationError(errors)
if len(outputs) == 0: if len(outputs) == 0:
raise ValidationError(_('A list of build outputs must be provided')) raise ValidationError(_('A list of build outputs must be provided'))
+97 -4
View File
@@ -11,7 +11,7 @@ from build.models import Build, BuildItem, BuildLine
from build.status_codes import BuildStatus from build.status_codes import BuildStatus
from common.settings import set_global_setting from common.settings import set_global_setting
from InvenTree.unit_test import InvenTreeAPITestCase from InvenTree.unit_test import InvenTreeAPITestCase
from part.models import BomItem, BomItemSubstitute, Part from part.models import BomItem, BomItemSubstitute, Part, PartTestTemplate
from stock.models import StockItem, StockLocation, StockSortOrder from stock.models import StockItem, StockLocation, StockSortOrder
from stock.status_codes import StockStatus from stock.status_codes import StockStatus
@@ -1531,10 +1531,15 @@ class BuildOutputScrapTest(BuildAPITest):
'notes': 'Partial complete', 'notes': 'Partial complete',
} }
# Ensure that an invalid quantity raises an error # Ensure that an invalid quantity raises an error, with the expected message
for q in [-4, 0, 999]: for q, expected_message in [
(-4, 'Ensure this value is greater than or equal to 0'),
(0, 'Quantity must be greater than zero'),
(999, 'Quantity cannot be greater than the output quantity'),
]:
data['outputs'][0]['quantity'] = q data['outputs'][0]['quantity'] = q
self.post(url, data, expected_code=400) response = self.post(url, data, expected_code=400)
self.assertIn(expected_message, str(response.data))
# Partially complete the output (with a valid quantity) # Partially complete the output (with a valid quantity)
data['outputs'][0]['quantity'] = 4 data['outputs'][0]['quantity'] = 4
@@ -1552,6 +1557,94 @@ class BuildOutputScrapTest(BuildAPITest):
self.assertEqual(completed_output.status, StockStatus.OK) self.assertEqual(completed_output.status, StockStatus.OK)
self.assertFalse(completed_output.is_building) self.assertFalse(completed_output.is_building)
def test_complete_with_required_tests(self):
"""Test that build output completion is blocked if required tests have not passed."""
build = Build.objects.get(pk=1)
output = build.create_build_output(1).first()
template = PartTestTemplate.objects.create(
part=build.part, test_name='Required test', required=True
)
set_global_setting(
'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS', True, change_user=None
)
url = reverse('api-build-output-complete', kwargs={'pk': build.pk})
data = {'outputs': [{'output': output.pk}], 'location': 1}
response = self.post(url, data, expected_code=400)
self.assertIn(
'Build output has not passed all required tests', str(response.data)
)
# Add a passing test result - the output should now be able to be completed
output.add_test_result(template=template, result=True)
self.post(url, data, expected_code=200)
def test_complete_still_in_production(self):
"""Test that build output completion is blocked if an allocated item is still in production."""
build = Build.objects.get(pk=1)
output = build.create_build_output(1).first()
build.create_build_line_items()
line = build.build_lines.first()
sub_build = Build.objects.create(
part=line.bom_item.sub_part,
quantity=1,
title='Sub-build',
reference='BO-9998',
)
in_production = StockItem.objects.create(
part=line.bom_item.sub_part, quantity=1, is_building=True, build=sub_build
)
BuildItem.objects.create(
build_line=line, stock_item=in_production, quantity=1, install_into=output
)
url = reverse('api-build-output-complete', kwargs={'pk': build.pk})
response = self.post(
url, {'outputs': [{'output': output.pk}], 'location': 1}, expected_code=400
)
self.assertIn(
'Allocated stock items are still in production', str(response.data)
)
def test_partial_complete_with_allocated_items(self):
"""Test that a build output with allocated items cannot be partially completed."""
build = Build.objects.get(pk=1)
output = build.create_build_output(10).first()
build.create_build_line_items()
line = build.build_lines.first()
stock_item = StockItem.objects.create(part=line.bom_item.sub_part, quantity=10)
BuildItem.objects.create(
build_line=line, stock_item=stock_item, quantity=1, install_into=output
)
url = reverse('api-build-output-complete', kwargs={'pk': build.pk})
response = self.post(
url,
{'outputs': [{'output': output.pk, 'quantity': 4}], 'location': 1},
expected_code=400,
)
self.assertIn(
'Cannot partially complete a build output with allocated items',
str(response.data),
)
class BuildOutputCancelTest(BuildAPITest): class BuildOutputCancelTest(BuildAPITest):
"""Test cancellation of build outputs.""" """Test cancellation of build outputs."""
+58 -1
View File
@@ -650,11 +650,15 @@ class BuildTest(BuildTestBase):
'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS', True, change_user=None 'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS', True, change_user=None
) )
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError) as exc:
self.build_w_tests_trackable.complete_build_output( self.build_w_tests_trackable.complete_build_output(
self.stockitem_with_required_test, None self.stockitem_with_required_test, None
) )
self.assertIn(
'Build output has not passed all required tests', str(exc.exception)
)
# let's complete the required test and see if it could be saved # let's complete the required test and see if it could be saved
StockItemTestResult.objects.create( StockItemTestResult.objects.create(
stock_item=self.stockitem_with_required_test, stock_item=self.stockitem_with_required_test,
@@ -671,6 +675,59 @@ class BuildTest(BuildTestBase):
self.stockitem_wo_required_test, None self.stockitem_wo_required_test, None
) )
def test_complete_output_still_in_production(self):
"""Test that a build output cannot be completed if allocated stock is still in production."""
# Create a stock item of the tracked sub-part, which is itself still "in production"
sub_build = Build.objects.create(
reference=generate_next_build_reference(),
title='Building a sub-part',
part=self.sub_part_3,
quantity=2,
issued_by=get_user_model().objects.get(pk=1),
)
in_production = StockItem.objects.create(
part=self.sub_part_3, quantity=2, is_building=True, build=sub_build
)
self.allocate_stock(self.output_1, {in_production: 2})
with self.assertRaises(ValidationError) as exc:
self.build.complete_build_output(self.output_1, None)
self.assertIn(
'Allocated stock items are still in production', str(exc.exception)
)
def test_partial_complete_with_allocated_items(self):
"""Test that a build output with tracked allocations cannot be partially completed."""
# Allocate tracked stock against output_1 (quantity=3)
self.allocate_stock(self.output_1, {self.stock_3_1: 6})
with self.assertRaises(ValidationError) as exc:
self.build.complete_build_output(self.output_1, None, quantity=1)
self.assertIn(
'Cannot partially complete a build output with allocated items',
str(exc.exception),
)
def test_complete_output_invalid_quantity(self):
"""Test that invalid quantities are rejected when completing a build output directly."""
with self.assertRaises(ValidationError) as exc:
self.build.complete_build_output(self.output_1, None, quantity=0)
self.assertIn('Quantity must be greater than zero', str(exc.exception))
with self.assertRaises(ValidationError) as exc:
self.build.complete_build_output(
self.output_1, None, quantity=self.output_1.quantity + 1
)
self.assertIn(
'Quantity cannot be greater than the output quantity', str(exc.exception)
)
def test_overdue_notification(self): def test_overdue_notification(self):
"""Test sending of notifications when a build order is overdue.""" """Test sending of notifications when a build order is overdue."""
self.ensurePluginsLoaded() self.ensurePluginsLoaded()
+10
View File
@@ -21,3 +21,13 @@ def validate_build_order_reference(value):
# If we get to here, run the "default" validation routine # If we get to here, run the "default" validation routine
Build.validate_reference_field(value) Build.validate_reference_field(value)
def check_build_output(output, quantity=None):
"""Run a validation check against each output before accepting it for completion.
Arguments:
output (StockItem): The build output to check
quantity (Decimal, optional): The quantity to complete. If None, the full output quantity is assumed.
"""
output.build.can_complete_output(output, quantity=quantity)
-9
View File
@@ -142,12 +142,3 @@ def stock_expiry_enabled():
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
return InvenTreeSetting.get_setting('STOCK_ENABLE_EXPIRY', False, create=False) return InvenTreeSetting.get_setting('STOCK_ENABLE_EXPIRY', False, create=False)
def prevent_build_output_complete_on_incompleted_tests():
"""Returns True if the completion of the build outputs is disabled until the required tests are passed."""
from common.models import InvenTreeSetting
return InvenTreeSetting.get_setting(
'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS', False, create=False
)
@@ -1,6 +1,7 @@
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { import {
ActionIcon,
Alert, Alert,
Container, Container,
Group, Group,
@@ -9,7 +10,10 @@ import {
Table, Table,
Text Text
} from '@mantine/core'; } from '@mantine/core';
import { IconExclamationCircle } from '@tabler/icons-react'; import {
IconCornerDownRight,
IconExclamationCircle
} from '@tabler/icons-react';
import { import {
type ReactNode, type ReactNode,
memo, memo,
@@ -63,13 +67,33 @@ function TableFieldRow({
); );
} }
return modelRenderer({ const nonFieldErrors = rowErrors?.non_field_errors;
item: item,
rowId: rowId, return (
rowErrors: rowErrors, <>
changeFn: changeFn, {modelRenderer({
removeFn: removeFn item: item,
}); rowId: rowId,
rowErrors: rowErrors,
changeFn: changeFn,
removeFn: removeFn
})}
{nonFieldErrors && (
<Table.Tr key={`table-row-${rowId}-non-field-errors`}>
<Table.Td colSpan={columnCount}>
<Group gap='xs'>
<ActionIcon size='sm' variant='transparent' c='red'>
<IconCornerDownRight />
</ActionIcon>
<Text size='xs' c='red'>
{nonFieldErrors.message ?? nonFieldErrors}
</Text>
</Group>
</Table.Td>
</Table.Tr>
)}
</>
);
} }
// Memoize each table field row, so that we don't re-render the entire table when a single row is updated // Memoize each table field row, so that we don't re-render the entire table when a single row is updated