2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-10-15 13:42:20 +00:00

Partial build output complete (#10499)

* Handle partial completion of build output

* Add 'quantity' field to BuildOutputComplete API endpoint

* Allow partial scrapping of build outputs

* Adjust column text

* Adjust "complete build output" form

* Change order of operations when completing build output

- Run validation checks *before* potentially splitting stock item

* Extract quantity from serializer

* Documentation

- Update screenshots
- Add note on partial completion
- Add note on partial scrapping

* Update CHANGELOG.md

* Update API version

* Add unit test for partial scrapping

* Tweak text

* Unit test for partial output completion

* Fix validation check for quantity field

* Adjust playwright tests
This commit is contained in:
Oliver
2025-10-06 14:30:07 +11:00
committed by GitHub
parent 946418e175
commit 5743e22501
11 changed files with 215 additions and 25 deletions

View File

@@ -1,18 +1,22 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 402
INVENTREE_API_VERSION = 403
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v403 -> 2025-10-06: https://github.com/inventree/InvenTree/pull/10499
- Adds ability to partially scrap a build output
- Adds ability to partially complete a build output
v402 -> 2025-10-05 : https://github.com/inventree/InvenTree/pull/10495
- Refactors 'part_detail' param in BuildList API endpoint
- Refactors 'order_detail' param in GeneralExtraLineList API endpoint
- Refactors 'part_detail', 'template_detail' param in PartParameterList / PartParameterDetail API endpoint
v401 -> 2025-10-04 : https://github.com/inventree/InvenTree/pull/10381
v401 -> 2025-10-05 : https://github.com/inventree/InvenTree/pull/10381
- Adds machine properties to machine API endpoints
v400 -> 2025-10-05 : https://github.com/inventree/InvenTree/pull/10486

View File

@@ -1115,7 +1115,9 @@ class Build(
items.all().delete()
@transaction.atomic
def scrap_build_output(self, output, quantity, location, **kwargs):
def scrap_build_output(
self, output: stock.models.StockItem, quantity, location, **kwargs
):
"""Mark a particular build output as scrapped / rejected.
- Mark the output as "complete"
@@ -1126,6 +1128,10 @@ class Build(
if not output:
raise ValidationError(_('No build output specified'))
# If quantity is not specified, assume the entire output quantity
if quantity is None:
quantity = output.quantity
if quantity <= 0:
raise ValidationError({'quantity': _('Quantity must be greater than zero')})
@@ -1140,7 +1146,9 @@ class Build(
if quantity < output.quantity:
# Split output into two items
output = output.splitStock(quantity, location=location, user=user)
output = output.splitStock(
quantity, location=location, user=user, allow_production=True
)
output.build = self
# Update build output item
@@ -1171,20 +1179,29 @@ class Build(
)
@transaction.atomic
def complete_build_output(self, output, user, **kwargs):
def complete_build_output(
self,
output: stock.models.StockItem,
user: User,
quantity: Optional[decimal.Decimal] = None,
**kwargs,
):
"""Complete a particular build output.
- Remove allocated StockItems
- Mark the output as complete
Arguments:
output: The StockItem instance (build output) to complete
user: The user who is completing the build output
quantity: The quantity to complete (defaults to entire output quantity)
Notes:
- Remove allocated StockItems
- Mark the output as complete
"""
# Select the location for the build output
location = kwargs.get('location', self.destination)
status = kwargs.get('status', StockStatus.OK.value)
notes = kwargs.get('notes', '')
# List the allocated BuildItem objects for the given output
allocated_items = output.items_to_install.all()
required_tests = kwargs.get('required_tests', output.part.getRequiredTests())
prevent_on_incomplete = kwargs.get(
'prevent_on_incomplete',
@@ -1201,6 +1218,30 @@ class Build(
raise ValidationError(msg)
# List the allocated BuildItem objects for the given output
allocated_items = output.items_to_install.all()
# If a partial quantity is provided, split the stock output
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
output = output.splitStock(quantity, user=user, allow_production=True)
for build_item in allocated_items:
# Complete the allocation of stock for that item
build_item.complete_allocation(user=user)

View File

@@ -269,7 +269,7 @@ class BuildOutputQuantitySerializer(BuildOutputSerializer):
max_digits=15,
decimal_places=5,
min_value=Decimal(0),
required=True,
required=False,
label=_('Quantity'),
help_text=_('Enter quantity for build output'),
)
@@ -281,13 +281,16 @@ class BuildOutputQuantitySerializer(BuildOutputSerializer):
output = data.get('output')
quantity = data.get('quantity')
if quantity <= 0:
raise ValidationError({'quantity': _('Quantity must be greater than zero')})
if quantity is not None:
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')
})
if quantity > output.quantity:
raise ValidationError({
'quantity': _('Quantity cannot be greater than the output quantity')
})
return data
@@ -532,7 +535,7 @@ class BuildOutputScrapSerializer(serializers.Serializer):
with transaction.atomic():
for item in outputs:
output = item['output']
quantity = item['quantity']
quantity = item.get('quantity', None)
build.scrap_build_output(
output,
quantity,
@@ -557,7 +560,7 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
'notes',
]
outputs = BuildOutputSerializer(many=True, required=True)
outputs = BuildOutputQuantitySerializer(many=True, required=True)
location = serializers.PrimaryKeyRelatedField(
queryset=StockLocation.objects.all(),
@@ -636,10 +639,12 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
with transaction.atomic():
for item in outputs:
output = item['output']
quantity = item.get('quantity', None)
build.complete_build_output(
output,
request.user if request else None,
quantity=quantity,
location=location,
status=status,
notes=notes,

View File

@@ -1349,6 +1349,77 @@ class BuildOutputScrapTest(BuildAPITest):
self.assertEqual(output.status, StockStatus.REJECTED)
self.assertFalse(output.is_building)
def test_partial_scrap(self):
"""Test partial scrapping of a build output."""
# Create a build output
build = Build.objects.get(pk=1)
output = build.create_build_output(10).first()
self.assertEqual(build.build_outputs.count(), 1)
data = {
'outputs': [{'output': output.pk, 'quantity': 3}],
'location': 1,
'notes': 'Invalid scrap',
}
# Ensure that an invalid quantity raises an error
for q in [-3, 0, 99]:
data['outputs'][0]['quantity'] = q
self.scrap(build.pk, data, expected_code=400)
# Partially scrap the output (with a valid quantity)
data['outputs'][0]['quantity'] = 3
self.scrap(build.pk, data)
self.assertEqual(build.build_outputs.count(), 2)
output.refresh_from_db()
self.assertEqual(output.quantity, 7)
self.assertTrue(output.is_building)
scrapped = output.children.first()
self.assertEqual(scrapped.quantity, 3)
self.assertEqual(scrapped.status, StockStatus.REJECTED)
self.assertFalse(scrapped.is_building)
def test_partial_complete(self):
"""Test partial completion of a build output."""
build = Build.objects.get(pk=1)
output = build.create_build_output(10).first()
self.assertEqual(build.build_outputs.count(), 1)
self.assertEqual(output.quantity, 10)
self.assertTrue(output.is_building)
self.assertEqual(build.completed, 0)
url = reverse('api-build-output-complete', kwargs={'pk': build.pk})
data = {
'outputs': [{'output': output.pk, 'quantity': 4}],
'location': 1,
'notes': 'Partial complete',
}
# Ensure that an invalid quantity raises an error
for q in [-4, 0, 999]:
data['outputs'][0]['quantity'] = q
self.post(url, data, expected_code=400)
# Partially complete the output (with a valid quantity)
data['outputs'][0]['quantity'] = 4
self.post(url, data, expected_code=201)
build.refresh_from_db()
output.refresh_from_db()
self.assertEqual(build.completed, 4)
self.assertEqual(build.build_outputs.count(), 2)
self.assertEqual(output.quantity, 6)
self.assertTrue(output.is_building)
completed_output = output.children.first()
self.assertEqual(completed_output.quantity, 4)
self.assertEqual(completed_output.status, StockStatus.OK)
self.assertFalse(completed_output.is_building)
class BuildLineTests(BuildAPITest):
"""Unit tests for the BuildLine API endpoints."""

View File

@@ -2209,6 +2209,7 @@ class StockItem(
batch: If provided, override the batch (default = existing batch)
status: If provided, override the status (default = existing status)
packaging: If provided, override the packaging (default = existing packaging)
allow_production: If True, allow splitting of stock which is in production (default = False)
Returns:
The new StockItem object
@@ -2221,8 +2222,10 @@ class StockItem(
"""
# Run initial checks to test if the stock item can actually be "split"
allow_production = kwargs.get('allow_production', False)
# Cannot split a stock item which is in production
if self.is_building:
if self.is_building and not allow_production:
raise ValidationError(_('Stock item is currently in production'))
notes = kwargs.get('notes', '')