mirror of
https://github.com/inventree/InvenTree.git
synced 2025-10-25 10:27:39 +00:00
Merge branch 'master' into make-fields-filterable
This commit is contained in:
@@ -1,13 +1,22 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 401
|
||||
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 = """
|
||||
|
||||
v401 -> 2025-10-04 : https://github.com/inventree/InvenTree/pull/10381
|
||||
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-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
|
||||
|
||||
@@ -218,7 +218,7 @@ class InvenTreeOutputOption:
|
||||
"""Represents an available output option with description, flag name, and default value."""
|
||||
|
||||
DEFAULT_DESCRIPTIONS = {
|
||||
'part_detail': 'Include detailed information about the related part in the response',
|
||||
'part_detail': 'Include detailed information about the related part in the response',
|
||||
'item_detail': 'Include detailed information about the item in the response',
|
||||
'order_detail': 'Include detailed information about the sales order in the response',
|
||||
'location_detail': 'Include detailed information about the stock location in the response',
|
||||
@@ -231,7 +231,7 @@ class InvenTreeOutputOption:
|
||||
self.flag = flag
|
||||
self.default = default
|
||||
|
||||
if description is None:
|
||||
if description is None or description == '':
|
||||
self.description = self.DEFAULT_DESCRIPTIONS.get(flag, '')
|
||||
else:
|
||||
self.description = description
|
||||
|
||||
@@ -332,17 +332,22 @@ class BuildMixin:
|
||||
return queryset
|
||||
|
||||
|
||||
class BuildList(DataExportViewMixin, BuildMixin, ListCreateAPI):
|
||||
class BuildListOutputOptions(OutputConfiguration):
|
||||
"""Output options for the BuildList endpoint."""
|
||||
|
||||
OPTIONS = [InvenTreeOutputOption('part_detail', default=True)]
|
||||
|
||||
|
||||
class BuildList(DataExportViewMixin, BuildMixin, OutputOptionsMixin, ListCreateAPI):
|
||||
"""API endpoint for accessing a list of Build objects.
|
||||
|
||||
- GET: Return list of objects (with filters)
|
||||
- POST: Create a new Build object
|
||||
"""
|
||||
|
||||
output_options = BuildListOutputOptions
|
||||
filterset_class = BuildFilter
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
|
||||
ordering_fields = [
|
||||
'reference',
|
||||
'part__name',
|
||||
@@ -360,14 +365,11 @@ class BuildList(DataExportViewMixin, BuildMixin, ListCreateAPI):
|
||||
'level',
|
||||
'external',
|
||||
]
|
||||
|
||||
ordering_field_aliases = {
|
||||
'reference': ['reference_int', 'reference'],
|
||||
'project_code': ['project_code__code'],
|
||||
}
|
||||
|
||||
ordering = '-reference'
|
||||
|
||||
search_fields = [
|
||||
'reference',
|
||||
'title',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -270,7 +270,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'),
|
||||
)
|
||||
@@ -282,13 +282,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
|
||||
|
||||
@@ -533,7 +536,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,
|
||||
@@ -558,7 +561,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(),
|
||||
@@ -637,10 +640,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."""
|
||||
|
||||
@@ -58,6 +58,12 @@ from part.models import Part
|
||||
from users.models import Owner
|
||||
|
||||
|
||||
class GeneralExtraLineListOutputOptions(OutputConfiguration):
|
||||
"""Output options for the GeneralExtraLineList endpoint."""
|
||||
|
||||
OPTIONS = [InvenTreeOutputOption('order_detail')]
|
||||
|
||||
|
||||
class GeneralExtraLineList(SerializerContextMixin, DataExportViewMixin):
|
||||
"""General template for ExtraLine API classes."""
|
||||
|
||||
@@ -69,6 +75,8 @@ class GeneralExtraLineList(SerializerContextMixin, DataExportViewMixin):
|
||||
|
||||
return queryset
|
||||
|
||||
output_options = GeneralExtraLineListOutputOptions
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
ordering_fields = ['quantity', 'notes', 'reference']
|
||||
@@ -733,7 +741,9 @@ class PurchaseOrderLineItemDetail(
|
||||
output_options = PurchaseOrderLineItemOutputOptions
|
||||
|
||||
|
||||
class PurchaseOrderExtraLineList(GeneralExtraLineList, ListCreateAPI):
|
||||
class PurchaseOrderExtraLineList(
|
||||
GeneralExtraLineList, OutputOptionsMixin, ListCreateAPI
|
||||
):
|
||||
"""API endpoint for accessing a list of PurchaseOrderExtraLine objects."""
|
||||
|
||||
queryset = models.PurchaseOrderExtraLine.objects.all()
|
||||
@@ -1070,7 +1080,7 @@ class SalesOrderLineItemDetail(
|
||||
output_options = SalesOrderLineItemOutputOptions
|
||||
|
||||
|
||||
class SalesOrderExtraLineList(GeneralExtraLineList, ListCreateAPI):
|
||||
class SalesOrderExtraLineList(GeneralExtraLineList, OutputOptionsMixin, ListCreateAPI):
|
||||
"""API endpoint for accessing a list of SalesOrderExtraLine objects."""
|
||||
|
||||
queryset = models.SalesOrderExtraLine.objects.all()
|
||||
@@ -1660,7 +1670,7 @@ class ReturnOrderLineItemDetail(
|
||||
output_options = ReturnOrderLineItemOutputOptions
|
||||
|
||||
|
||||
class ReturnOrderExtraLineList(GeneralExtraLineList, ListCreateAPI):
|
||||
class ReturnOrderExtraLineList(GeneralExtraLineList, OutputOptionsMixin, ListCreateAPI):
|
||||
"""API endpoint for accessing a list of ReturnOrderExtraLine objects."""
|
||||
|
||||
queryset = models.ReturnOrderExtraLine.objects.all()
|
||||
|
||||
@@ -1365,11 +1365,25 @@ class PartParameterTemplateDetail(PartParameterTemplateMixin, RetrieveUpdateDest
|
||||
"""API endpoint for accessing the detail view for a PartParameterTemplate object."""
|
||||
|
||||
|
||||
class PartParameterOutputOptions(OutputConfiguration):
|
||||
"""Output options for the PartParameter endpoints."""
|
||||
|
||||
OPTIONS = [
|
||||
InvenTreeOutputOption('part_detail'),
|
||||
InvenTreeOutputOption(
|
||||
'template_detail',
|
||||
default=True,
|
||||
description='Include detailed information about the part parameter template.',
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class PartParameterAPIMixin:
|
||||
"""Mixin class for PartParameter API endpoints."""
|
||||
|
||||
queryset = PartParameter.objects.all()
|
||||
serializer_class = part_serializers.PartParameterSerializer
|
||||
output_options = PartParameterOutputOptions
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
"""Override get_queryset method to prefetch related fields."""
|
||||
@@ -1414,7 +1428,9 @@ class PartParameterFilter(FilterSet):
|
||||
return queryset.filter(part=part)
|
||||
|
||||
|
||||
class PartParameterList(PartParameterAPIMixin, DataExportViewMixin, ListCreateAPI):
|
||||
class PartParameterList(
|
||||
PartParameterAPIMixin, OutputOptionsMixin, DataExportViewMixin, ListCreateAPI
|
||||
):
|
||||
"""API endpoint for accessing a list of PartParameter objects.
|
||||
|
||||
- GET: Return list of PartParameter objects
|
||||
@@ -1442,7 +1458,9 @@ class PartParameterList(PartParameterAPIMixin, DataExportViewMixin, ListCreateAP
|
||||
]
|
||||
|
||||
|
||||
class PartParameterDetail(PartParameterAPIMixin, RetrieveUpdateDestroyAPI):
|
||||
class PartParameterDetail(
|
||||
PartParameterAPIMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI
|
||||
):
|
||||
"""API endpoint for detail view of a single PartParameter object."""
|
||||
|
||||
|
||||
|
||||
@@ -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', '')
|
||||
|
||||
@@ -51,7 +51,11 @@ export function BuiltinQueryCountWidgets(): DashboardWidgetProps[] {
|
||||
label: 'low-stk',
|
||||
description: t`Show the number of parts which are low on stock`,
|
||||
modelType: ModelType.part,
|
||||
params: { low_stock: true, active: true }
|
||||
params: {
|
||||
active: true,
|
||||
low_stock: true,
|
||||
virtual: false
|
||||
}
|
||||
}),
|
||||
QueryCountDashboardWidget({
|
||||
title: t`Required for Build Orders`,
|
||||
|
||||
@@ -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