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:
@@ -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
|
||||
|
@@ -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)
|
||||
|
@@ -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,
|
||||
|
@@ -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."""
|
||||
|
@@ -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', '')
|
||||
|
Reference in New Issue
Block a user