2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-10-14 21:22: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

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -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.

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', '')

View File

@@ -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 (
<StandaloneField
fieldName='quantity'
fieldDefinition={{
field_type: 'number',
required: true,
value: props.item.quantity,
onValueChange: (value: any) => {
props.changeFn(props.idx, 'quantity', value);
}
}}
error={props.rowErrors?.quantity?.message}
/>
);
}, [props, record]);
return (
<>
<Table.Tr>
<Table.Td>
<RenderPartColumn part={record.part_detail} />
</Table.Td>
<Table.Td>{stockItemColumn}</Table.Td>
<Table.Td>
<TableFieldErrorWrapper props={props} errorKey='output'>
{serial}
{quantityColumn}
</TableFieldErrorWrapper>
</Table.Td>
<Table.Td>{record.batch}</Table.Td>
@@ -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' } }

View File

@@ -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 }) => {