diff --git a/CHANGELOG.md b/CHANGELOG.md index 3619673796..b8b070d33b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `order_queryset` report helper function in [#10439](https://github.com/inventree/InvenTree/pull/10439) - Added much more detailed status information for machines to the API endpoint (including backend and frontend changes) in [#10381](https://github.com/inventree/InvenTree/pull/10381) +- Added ability to partially complete and partially scrap build outputs in [#10499](https://github.com/inventree/InvenTree/pull/10499) ### Changed diff --git a/docs/docs/assets/images/build/build_output_complete.png b/docs/docs/assets/images/build/build_output_complete.png index 4e0777383c..921a1a40a6 100644 Binary files a/docs/docs/assets/images/build/build_output_complete.png and b/docs/docs/assets/images/build/build_output_complete.png differ diff --git a/docs/docs/assets/images/build/build_output_scrap.png b/docs/docs/assets/images/build/build_output_scrap.png index 0a29cd13f3..397cfad3f9 100644 Binary files a/docs/docs/assets/images/build/build_output_scrap.png and b/docs/docs/assets/images/build/build_output_scrap.png differ diff --git a/docs/docs/manufacturing/output.md b/docs/docs/manufacturing/output.md index f93de5107c..018fc666dc 100644 --- a/docs/docs/manufacturing/output.md +++ b/docs/docs/manufacturing/output.md @@ -70,6 +70,18 @@ The following options are available when completing a build output: | Notes | Any additional notes associated with the completion of these outputs | | Accept Incomplete Allocation | If selected, this option allows [tracked build outputs](./allocate.md#tracked-build-outputs) to be completed in the case where required BOM items have not been fully allocated | +### Partial Completion + +A build output may be *partially completed* by specifying a quantity less than the total quantity of that build output. In such a case, the following actions are performed: + +- The incomplete build output is "split" into two separate build outputs +- The specified quantity is marked as completed, and the completed build quantity is increased accordingly +- The remaining quantity is left as an incomplete build output, available for future completion + +!!! note "Serialized Outputs" + Serialized build outputs cannot be partially completed. + + ## Scrap Build Output *Scrapping* a build output marks the particular output as rejected, in the context of the given build order. @@ -95,6 +107,17 @@ The following options are available when scrapping a build order: | Notes | Any additional notes associated with the scrapping of these outputs | | Discard Allocations | If selected, any installed BOM items will be removed first, before marking the build output as scrapped. Use this option if the installed items are recoverable and can be used elsewhere | +### Partial Scrapping + +A build output may be *partially scrapped* by specifying a quantity less than the total quantity of that build output. In such a case, the following actions are performed: + +- The incomplete build output is "split" into two separate build outputs +- The specified quantity is marked as scrapped, and the completed build quantity is *not* increased +- The remaining quantity is left as an incomplete build output, available for future completion + +!!! note "Serialized Outputs" + Serialized build outputs cannot be partially scrapped. + ## Cancel Build Output *Cancelling* a build output causes the build output to be deleted, and removed from the database entirely. Use this option when the build output does not physically exist (or was never built) and should not be tracked in the database. diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index c83990d960..cd4ed734b8 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index 7264bbb7d0..e4c89c5d63 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -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) diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index 9361ad7ff4..5ed08c80db 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -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, diff --git a/src/backend/InvenTree/build/test_api.py b/src/backend/InvenTree/build/test_api.py index da8ea3c4c9..a941de65a2 100644 --- a/src/backend/InvenTree/build/test_api.py +++ b/src/backend/InvenTree/build/test_api.py @@ -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.""" diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index 5a0ce08244..7718dd0a00 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -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', '') diff --git a/src/frontend/src/forms/BuildForms.tsx b/src/frontend/src/forms/BuildForms.tsx index a1389dc973..b975e0054f 100644 --- a/src/frontend/src/forms/BuildForms.tsx +++ b/src/frontend/src/forms/BuildForms.tsx @@ -236,7 +236,7 @@ function BuildOutputFormRow({ props: TableFieldRowProps; record: any; }>) { - const serial = useMemo(() => { + const stockItemColumn = useMemo(() => { if (record.serial) { return `# ${record.serial}`; } else { @@ -244,15 +244,39 @@ function BuildOutputFormRow({ } }, [record]); + const quantityColumn = useMemo(() => { + // Serialized output - quantity cannot be changed + if (record.serial) { + return '1'; + } + + // Non-serialized output - quantity can be changed + return ( + { + props.changeFn(props.idx, 'quantity', value); + } + }} + error={props.rowErrors?.quantity?.message} + /> + ); + }, [props, record]); + return ( <> + {stockItemColumn} - {serial} + {quantityColumn} {record.batch} @@ -297,7 +321,8 @@ export function useCompleteBuildOutputsForm({ field_type: 'table', value: outputs.map((output: any) => { return { - output: output.pk + output: output.pk, + quantity: output.quantity }; }), modelRenderer: (row: TableFieldRowProps) => { @@ -309,6 +334,7 @@ export function useCompleteBuildOutputsForm({ headers: [ { title: t`Part` }, { title: t`Build Output` }, + { title: t`Quantity to Complete`, style: { width: '200px' } }, { title: t`Batch` }, { title: t`Status` }, { title: '', style: { width: '50px' } } @@ -382,7 +408,8 @@ export function useScrapBuildOutputsForm({ }, headers: [ { title: t`Part` }, - { title: t`Stock Item` }, + { title: t`Build Output` }, + { title: t`Quantity to Scrap`, style: { width: '200px' } }, { title: t`Batch` }, { title: t`Status` }, { title: '', style: { width: '50px' } } diff --git a/src/frontend/tests/pages/pui_build.spec.ts b/src/frontend/tests/pages/pui_build.spec.ts index 9fd3d41c27..28c9091223 100644 --- a/src/frontend/tests/pages/pui_build.spec.ts +++ b/src/frontend/tests/pages/pui_build.spec.ts @@ -206,7 +206,8 @@ test('Build Order - Build Outputs', async ({ browser }) => { await page.getByLabel('text-field-batch_code').fill('BATCH12345'); await page.getByLabel('related-field-location').click(); - await page.getByText('Reel Storage').click(); + await page.getByLabel('related-field-location').fill('Reel'); + await page.getByText('- Electronics Lab/Reel Storage').click(); await page.getByRole('button', { name: 'Submit' }).click(); // Should be an error as the number of serial numbers doesn't match the quantity @@ -246,6 +247,20 @@ test('Build Order - Build Outputs', async ({ browser }) => { await page.waitForTimeout(250); await page.getByRole('button', { name: 'Submit' }).click(); await page.getByText('Build outputs have been completed').waitFor(); + + // Check for expected UI elements in the "scrap output" dialog + const cell3 = await page.getByRole('cell', { name: '16' }); + const row3 = await getRowFromCell(cell3); + await row3.getByLabel(/row-action-menu-/i).click(); + await page.getByRole('menuitem', { name: 'Scrap' }).click(); + + await page + .getByText( + 'Selected build outputs will be completed, but marked as scrapped' + ) + .waitFor(); + await page.getByRole('cell', { name: 'Quantity: 16' }).waitFor(); + await page.getByRole('button', { name: 'Cancel', exact: true }).click(); }); test('Build Order - Allocation', async ({ browser }) => {