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