2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-03-21 11:44:42 +00:00

Build consume fix (#11529)

* Add new build task

* Refactor background task for consuming build stock

- Run as a single task
- Improve query efficiency

* Refactor consuming stock against build via API

- Return task_id for monitoring
- Keep frontend updated

* Task tracking for auto-allocation

* Add e2e integration tests:

- Auto-allocate stock
- Consume stock

* Bump API version

* Playwright test fixes

* Adjust unit tests

* Robustify unit test

* Widen test scope

* Adjust playwright test

* Loosen test requirements again

* idk, another change :|

* Robustify test
This commit is contained in:
Oliver
2026-03-17 20:51:12 +11:00
committed by GitHub
parent 97aec82d33
commit 84cd81d9a8
13 changed files with 283 additions and 185 deletions

View File

@@ -1,11 +1,15 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 464
INVENTREE_API_VERSION = 465
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v465 -> 2026-03-18 : https://github.com/inventree/InvenTree/pull/11529/
- BuildOrderAutoAllocate endpoint now returns a task ID which can be used to track the progress of the auto-allocation process
- BuildOrderConsume endpoint now returns a task ID which can be used to track the progress of the stock consumption process
v464 -> 2026-03-15 : https://github.com/inventree/InvenTree/pull/11527
- Add API endpoint for monitoring the progress of a particular background task

View File

@@ -11,7 +11,7 @@ import django_filters.rest_framework.filters as rest_filters
from django_filters.rest_framework.filterset import FilterSet
from drf_spectacular.utils import extend_schema, extend_schema_field
from rest_framework import serializers, status
from rest_framework.exceptions import ValidationError
from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.response import Response
import build.models as build_models
@@ -662,6 +662,13 @@ class BuildLineDetail(BuildLineMixin, OutputOptionsMixin, RetrieveUpdateDestroyA
class BuildOrderContextMixin:
"""Mixin class which adds build order as serializer context variable."""
def get_build(self):
"""Return the Build object associated with this API endpoint."""
try:
return Build.objects.get(pk=self.kwargs.get('pk', None))
except (ValueError, Build.DoesNotExist):
raise NotFound(_('Build not found'))
def get_serializer_context(self):
"""Add extra context information to the endpoint serializer."""
ctx = super().get_serializer_context()
@@ -670,8 +677,8 @@ class BuildOrderContextMixin:
ctx['to_complete'] = True
try:
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
except Exception:
ctx['build'] = self.get_build()
except NotFound:
pass
return ctx
@@ -764,6 +771,37 @@ class BuildAutoAllocate(BuildOrderContextMixin, CreateAPI):
queryset = Build.objects.none()
serializer_class = build.serializers.BuildAutoAllocationSerializer
@extend_schema(responses={200: common.serializers.TaskDetailSerializer})
def post(self, *args, **kwargs):
"""Override the POST method to handle auto allocation task.
As this is offloaded to the background task,
we return information about the background task which is performing the auto allocation operation.
"""
from build.tasks import auto_allocate_build
from InvenTree.tasks import offload_task
build = self.get_build()
serializer = self.get_serializer(data=self.request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
# Offload the task to the background worker
task_id = offload_task(
auto_allocate_build,
build.pk,
location=data.get('location', None),
exclude_location=data.get('exclude_location', None),
interchangeable=data['interchangeable'],
substitutes=data['substitutes'],
optional_items=data['optional_items'],
item_type=data.get('item_type', 'untracked'),
group='build',
)
response = common.serializers.TaskDetailSerializer.from_task(task_id).data
return Response(response, status=response['http_status'])
class BuildAllocate(BuildOrderContextMixin, CreateAPI):
"""API endpoint to allocate stock items to a build order.
@@ -786,6 +824,39 @@ class BuildConsume(BuildOrderContextMixin, CreateAPI):
queryset = Build.objects.none()
serializer_class = build.serializers.BuildConsumeSerializer
@extend_schema(responses={200: common.serializers.TaskDetailSerializer})
def post(self, *args, **kwargs):
"""Override the POST method to handle consume task.
As this is offloaded to the background task,
we return information about the background task which is performing the consume operation.
"""
from build.tasks import consume_build_stock
from InvenTree.tasks import offload_task
build = self.get_build()
serializer = self.get_serializer(data=self.request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
# Extract the information we need to consume build stock
items = data.get('items', [])
lines = data.get('lines', [])
notes = data.get('notes', '')
# Offload the task to the background worker
task_id = offload_task(
consume_build_stock,
build.pk,
lines=[line['build_line'].pk for line in lines],
items={item['build_item'].pk: item['quantity'] for item in items},
user_id=self.request.user.pk,
notes=notes,
)
response = common.serializers.TaskDetailSerializer.from_task(task_id).data
return Response(response, status=response['http_status'])
class BuildIssue(BuildOrderContextMixin, CreateAPI):
"""API endpoint for issuing a BuildOrder."""

View File

@@ -21,7 +21,6 @@ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from rest_framework.serializers import ValidationError
import build.tasks
import common.filters
import common.settings
import company.serializers
@@ -38,7 +37,6 @@ from InvenTree.serializers import (
NotesFieldMixin,
enable_filter,
)
from InvenTree.tasks import offload_task
from stock.generators import generate_batch_code
from stock.models import StockItem, StockLocation
from stock.serializers import (
@@ -51,7 +49,6 @@ from users.serializers import OwnerSerializer, UserSerializer
from .models import Build, BuildItem, BuildLine
from .status_codes import BuildStatus
from .tasks import consume_build_item, consume_build_line
class BuildSerializer(
@@ -1129,27 +1126,6 @@ class BuildAutoAllocationSerializer(serializers.Serializer):
help_text=_('Select item type to auto-allocate'),
)
def save(self):
"""Perform the auto-allocation step."""
import InvenTree.tasks
data = self.validated_data
build_order = self.context['build']
if not InvenTree.tasks.offload_task(
build.tasks.auto_allocate_build,
build_order.pk,
location=data.get('location', None),
exclude_location=data.get('exclude_location', None),
interchangeable=data['interchangeable'],
substitutes=data['substitutes'],
optional_items=data['optional_items'],
item_type=data.get('item_type', 'untracked'),
group='build',
):
raise ValidationError(_('Failed to start auto-allocation task'))
class BuildItemSerializer(
FilterableSerializerMixin, DataImportExportSerializerMixin, InvenTreeModelSerializer
@@ -1847,46 +1823,3 @@ class BuildConsumeSerializer(serializers.Serializer):
raise ValidationError(_('At least one item or line must be provided'))
return data
@transaction.atomic
def save(self):
"""Perform the stock consumption step."""
data = self.validated_data
request = self.context.get('request')
notes = data.get('notes', '')
# We may be passed either a list of BuildItem or BuildLine instances
items = data.get('items', [])
lines = data.get('lines', [])
with transaction.atomic():
# Process the provided BuildItem objects
for item in items:
build_item = item['build_item']
quantity = item['quantity']
if build_item.install_into:
# If the build item is tracked into an output, we do not consume now
# Instead, it gets consumed when the output is completed
continue
# Offload a background task to consume this BuildItem
offload_task(
consume_build_item,
build_item.pk,
quantity,
notes=notes,
user_id=request.user.pk if request else None,
)
# Process the provided BuildLine objects
for line in lines:
build_line = line['build_line']
# Offload a background task to consume this BuildLine
offload_task(
consume_build_line,
build_line.pk,
notes=notes,
user_id=request.user.pk if request else None,
)

View File

@@ -2,8 +2,10 @@
from datetime import timedelta
from decimal import Decimal
from typing import Optional
from django.contrib.auth.models import User
from django.db import transaction
from django.utils.translation import gettext_lazy as _
import structlog
@@ -27,61 +29,53 @@ def auto_allocate_build(build_id: int, **kwargs):
"""Run auto-allocation for a specified BuildOrder."""
from build.models import Build
build_order = Build.objects.filter(pk=build_id).first()
if not build_order:
logger.warning(
'Could not auto-allocate BuildOrder <%s> - BuildOrder does not exist',
build_id,
)
return
build_order = Build.objects.get(pk=build_id)
build_order.auto_allocate_stock(**kwargs)
@tracer.start_as_current_span('consume_build_item')
def consume_build_item(
item_id: str, quantity, notes: str = '', user_id: int | None = None
@tracer.start_as_current_span('consume_build_stock')
def consume_build_stock(
build_id: int,
lines: Optional[list[int]] = None,
items: Optional[dict] = None,
user_id: int | None = None,
**kwargs,
):
"""Consume stock against a particular BuildOrderLineItem allocation."""
from build.models import BuildItem
"""Consume stock for the specified BuildOrder.
item = BuildItem.objects.filter(pk=item_id).first()
Arguments:
build_id: The ID of the BuildOrder to consume stock for
lines: Optional list of BuildLine IDs to consume
items: Optional dict of BuildItem IDs (and quantities)to consume
user_id: The ID of the user who initiated the stock consumption
"""
from build.models import Build, BuildItem, BuildLine
if not item:
logger.warning(
'Could not consume stock for BuildItem <%s> - BuildItem does not exist',
item_id,
)
return
build = Build.objects.get(pk=build_id)
user = User.objects.filter(pk=user_id).first() if user_id else None
item.complete_allocation(
quantity=quantity,
notes=notes,
user=User.objects.filter(pk=user_id).first() if user_id else None,
)
lines = lines or []
items = items or {}
notes = kwargs.pop('notes', '')
# Extract the relevant BuildLine and BuildItem objects
with transaction.atomic():
# Consume each of the specified BuildLine objects
for line_id in lines:
if build_line := BuildLine.objects.filter(pk=line_id, build=build).first():
for item in build_line.allocations.all():
item.complete_allocation(
quantity=item.quantity, notes=notes, user=user
)
@tracer.start_as_current_span('consume_build_line')
def consume_build_line(line_id: int, notes: str = '', user_id: int | None = None):
"""Consume stock against a particular BuildOrderLineItem."""
from build.models import BuildLine
line_item = BuildLine.objects.filter(pk=line_id).first()
if not line_item:
logger.warning(
'Could not consume stock for LineItem <%s> - LineItem does not exist',
line_id,
)
return
for item in line_item.allocations.all():
item.complete_allocation(
quantity=item.quantity,
notes=notes,
user=User.objects.filter(pk=user_id).first() if user_id else None,
)
# Consume each of the specified BuildItem objects
for item_id, quantity in items.items():
if build_item := BuildItem.objects.filter(
pk=item_id, build_line__build=build
).first():
build_item.complete_allocation(
quantity=quantity, notes=notes, user=user
)
@tracer.start_as_current_span('complete_build_allocations')

View File

@@ -970,12 +970,12 @@ class BuildAllocationTest(BuildAPITest):
url = reverse('api-build-auto-allocate', kwargs={'pk': build.pk})
# Allocate only 'untracked' items - this should not allocate our tracked item
self.post(url, data={'item_type': 'untracked'})
self.post(url, data={'item_type': 'untracked'}, expected_code=200)
self.assertEqual(N, BuildItem.objects.count())
# Allocate 'tracked' items - this should allocate our tracked item
self.post(url, data={'item_type': 'tracked'})
self.post(url, data={'item_type': 'tracked'}, expected_code=200)
# A new BuildItem should have been created
self.assertEqual(N + 1, BuildItem.objects.count())
@@ -1735,7 +1735,7 @@ class BuildConsumeTest(BuildAPITest):
'lines': [{'build_line': line.pk} for line in self.build.build_lines.all()]
}
self.post(url, data, expected_code=201)
self.post(url, data, expected_code=200)
self.assertEqual(self.build.allocated_stock.count(), 0)
self.assertEqual(self.build.consumed_stock.count(), 3)
@@ -1758,7 +1758,7 @@ class BuildConsumeTest(BuildAPITest):
]
}
self.post(url, data, expected_code=201)
self.post(url, data, expected_code=200)
self.assertEqual(self.build.allocated_stock.count(), 0)
self.assertEqual(self.build.consumed_stock.count(), 3)

View File

@@ -853,7 +853,7 @@ export function useConsumeBuildItemsForm({
url: ApiEndpoints.build_order_consume,
pk: buildId,
title: t`Consume Stock`,
successMessage: t`Stock items scheduled to be consumed`,
successMessage: null,
onFormSuccess: onFormSuccess,
size: '80%',
fields: consumeFields,
@@ -954,7 +954,7 @@ export function useConsumeBuildLinesForm({
url: ApiEndpoints.build_order_consume,
pk: buildId,
title: t`Consume Stock`,
successMessage: t`Stock items scheduled to be consumed`,
successMessage: null,
onFormSuccess: onFormSuccess,
fields: consumeFields,
initialData: {

View File

@@ -13,6 +13,7 @@ import type { TableColumn } from '@lib/types/Tables';
import { Alert } from '@mantine/core';
import { IconCircleDashedCheck, IconCircleX } from '@tabler/icons-react';
import { useConsumeBuildItemsForm } from '../../forms/BuildForms';
import useBackgroundTask from '../../hooks/UseBackgroundTask';
import {
useDeleteApiFormModal,
useEditApiFormModal
@@ -189,12 +190,28 @@ export default function BuildAllocatedStockTable({
return selectedItems.filter((item) => !item.part_detail?.trackable);
}, [selectedItems]);
const [consumeTaskId, setConsumeTaskId] = useState<string>('');
useBackgroundTask({
taskId: consumeTaskId,
message: t`Consuming allocated stock`,
successMessage: t`Stock consumed successfully`,
onSuccess: () => {
table.refreshTable();
}
});
const consumeStock = useConsumeBuildItemsForm({
buildId: buildId ?? 0,
allocatedItems: itemsToConsume,
onFormSuccess: () => {
onFormSuccess: (response: any) => {
table.clearSelectedRecords();
table.refreshTable();
if (response.task_id) {
setConsumeTaskId(response.task_id);
} else {
table.refreshTable();
}
}
});

View File

@@ -31,6 +31,7 @@ import {
useBuildOrderFields,
useConsumeBuildLinesForm
} from '../../forms/BuildForms';
import useBackgroundTask from '../../hooks/UseBackgroundTask';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
@@ -569,6 +570,17 @@ export default function BuildLineTable({
modelType: ModelType.build
});
const [allocateTaskId, setAllocateTaskId] = useState<string>('');
useBackgroundTask({
taskId: allocateTaskId,
message: t`Allocating stock to build order`,
successMessage: t`Stock allocation complete`,
onSuccess: () => {
table.refreshTable();
}
});
const autoAllocateStock = useCreateApiFormModal({
url: ApiEndpoints.build_order_auto_allocate,
pk: build.pk,
@@ -582,8 +594,10 @@ export default function BuildLineTable({
substitutes: true,
optional_items: false
},
successMessage: t`Auto allocation in progress`,
table: table,
successMessage: null,
onFormSuccess: (response: any) => {
setAllocateTaskId(response.task_id);
},
preFormContent: (
<Alert color='green' title={t`Auto Allocate Stock`}>
<Text>{t`Automatically allocate untracked BOM items to this build according to the selected options`}</Text>
@@ -669,12 +683,28 @@ export default function BuildLineTable({
parts: partsToOrder
});
const [consumeTaskId, setConsumeTaskId] = useState<string>('');
useBackgroundTask({
taskId: consumeTaskId,
message: t`Consuming allocated stock`,
successMessage: t`Stock consumed successfully`,
onSuccess: () => {
table.refreshTable();
}
});
const consumeLines = useConsumeBuildLinesForm({
buildId: build.pk,
buildLines: selectedRows,
onFormSuccess: () => {
onFormSuccess: (response: any) => {
table.clearSelectedRecords();
table.refreshTable();
if (response.task_id) {
setConsumeTaskId(response.task_id);
} else {
table.refreshTable();
}
}
});

View File

@@ -45,6 +45,7 @@ import {
useStockItemSerializeFields
} from '../../forms/StockForms';
import { InvenTreeIcon } from '../../functions/icons';
import useBackgroundTask from '../../hooks/UseBackgroundTask';
import {
useCreateApiFormModal,
useEditApiFormModal
@@ -215,6 +216,17 @@ export default function BuildOutputTable({
}
});
const [allocateTaskId, setAllocateTaskId] = useState<string>('');
useBackgroundTask({
taskId: allocateTaskId,
message: t`Allocating stock to build order`,
successMessage: t`Stock allocation complete`,
onSuccess: () => {
refetchTrackedItems();
}
});
const autoAllocateStock = useCreateApiFormModal({
url: ApiEndpoints.build_order_auto_allocate,
pk: build.pk,
@@ -226,12 +238,9 @@ export default function BuildOutputTable({
location: build.take_from,
substitutes: true
},
successMessage: t`Auto-allocation in progress`,
onFormSuccess: () => {
// After a short delay, refresh the tracked items
setTimeout(() => {
refetchTrackedItems();
}, 2500);
successMessage: null,
onFormSuccess: (response: any) => {
setAllocateTaskId(response.task_id);
},
table: table,
preFormContent: (

View File

@@ -219,6 +219,7 @@ test('Build Order - Build Outputs', async ({ browser }) => {
await clearTableFilters(page);
// We have now loaded the "Build Order" table. Check for some expected texts
await page.getByRole('textbox', { name: 'table-search-input' }).fill('1');
await page.getByText('On Hold').first().waitFor();
await page.getByText('Pending').first().waitFor();
@@ -315,7 +316,7 @@ test('Build Order - Build Outputs', async ({ browser }) => {
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 cell3 = await page.getByRole('cell', { name: '16', exact: true });
const row3 = await getRowFromCell(cell3);
await row3.getByLabel(/row-action-menu-/i).click();
await page.getByRole('menuitem', { name: 'Scrap' }).click();
@@ -468,54 +469,69 @@ test('Build Order - Auto Allocate Tracked', async ({ browser }) => {
// Test partial stock consumption against build order
test('Build Order - Consume Stock', async ({ browser }) => {
const page = await doCachedLogin(browser, {
url: 'manufacturing/build-order/24/line-items'
url: 'manufacturing/build-order/28/line-items'
});
// Check for expected progress values
await page.getByText('2 / 2', { exact: true }).waitFor();
await page.getByText('8 / 10', { exact: true }).waitFor();
await page.getByText('5 / 35', { exact: true }).waitFor();
await page.getByText('5 / 40', { exact: true }).waitFor();
// Duplicate this build order, to ensure a fresh run each time
await page.getByRole('button', { name: 'action-menu-build-order-' }).click();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Item Created').waitFor();
// Open the "Allocate Stock" dialog
await page.getByRole('checkbox', { name: 'Select all records' }).check();
// Issue the order
await page.getByRole('button', { name: 'Issue Order' }).click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Order issued').waitFor();
// Navigate to the "required parts" tab - and auto-allocate stock
await loadTab(page, 'Required Parts');
await page
.getByRole('button', { name: 'action-button-allocate-stock' })
.getByRole('button', { name: 'action-button-auto-allocate-' })
.click();
await page
.getByLabel('Allocate Stock')
.getByText('5 / 35', { exact: true })
.waitFor();
await page.getByRole('button', { name: 'Cancel' }).click();
await page.getByRole('button', { name: 'Submit' }).click();
// Open the "Consume Stock" dialog
// Task progress should be updated by the background worker thread
await page.getByText('Allocating stock to build order').waitFor();
await page.getByText('Stock allocation complete').waitFor();
// Check for allocated stock
await page.getByText('15 / 15').waitFor();
await page.getByText('10 / 10').waitFor();
await page.getByText('5 / 5').waitFor();
// Consume a single allocated item against the order
await loadTab(page, 'Allocated Stock');
await page.getByRole('checkbox', { name: 'Select record 1' }).check();
await page
.getByRole('button', { name: 'action-button-consume-stock' })
.click();
await page.getByLabel('Consume Stock').getByText('2 / 2').waitFor();
await page.getByLabel('Consume Stock').getByText('8 / 10').waitFor();
await page.getByLabel('Consume Stock').getByText('5 / 35').waitFor();
await page.getByLabel('Consume Stock').getByText('5 / 40').waitFor();
await page
.getByRole('textbox', { name: 'text-field-notes', exact: true })
.fill('some notes here...');
await page.getByRole('button', { name: 'Cancel' }).click();
.getByRole('textbox', { name: 'text-field-notes' })
.fill('consuming a single item');
await page.waitForTimeout(250);
await page.getByRole('button', { name: 'Submit' }).click();
// Try with a different build order
await navigate(page, 'manufacturing/build-order/26/line-items');
// Confirm progress and success
await page.getByText('Consuming allocated stock').waitFor();
await page.getByText('Stock consumed successfully').waitFor();
// Consume the rest of the stock via line items
await loadTab(page, 'Required Parts');
await page.getByRole('checkbox', { name: 'Select all records' }).check();
await page
.getByRole('button', { name: 'action-button-consume-stock' })
.click();
await page.getByLabel('Consume Stock').getByText('306 / 1,900').waitFor();
await page
.getByLabel('Consume Stock')
.getByText('Fully consumed')
.first()
.waitFor();
.getByRole('textbox', { name: 'text-field-notes' })
.fill('consuming remaining items');
await page.waitForTimeout(250);
await page.getByRole('button', { name: 'Submit' }).click();
await page.waitForTimeout(1000);
await page.getByText('Consuming allocated stock').waitFor();
await page.getByText('Stock consumed successfully').waitFor();
await page.getByText('Fully consumed').first().waitFor();
await page.getByText('15 / 15').first().waitFor();
});
test('Build Order - Tracked Outputs', async ({ browser }) => {
@@ -523,7 +539,7 @@ test('Build Order - Tracked Outputs', async ({ browser }) => {
url: 'manufacturing/build-order/10/incomplete-outputs'
});
const cancelBuildOutput = async (cell) => {
const cancelBuildOutput = async (cell: any) => {
await clickOnRowMenu(cell);
await page.getByRole('menuitem', { name: 'Cancel' }).click();
await page.getByRole('button', { name: 'Submit', exact: true }).click();
@@ -633,9 +649,11 @@ test('Build Order - Filters', async ({ browser }) => {
// Toggle 'Outstanding' filter
await setTableChoiceFilter(page, 'Outstanding', 'Yes');
await page.getByRole('textbox', { name: 'table-search-input' }).fill('1');
await page.getByRole('cell', { name: 'BO0017' }).waitFor();
await clearTableFilters(page);
await page.getByRole('textbox', { name: 'table-search-input' }).fill('');
await setTableChoiceFilter(page, 'Outstanding', 'No');
await page.getByText('1 - 6 / 6').waitFor();

View File

@@ -147,20 +147,7 @@ test('Parts - BOM', async ({ browser }) => {
test('Parts - BOM Validation', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'part/107/bom' });
// Run BOM validation step
await page
.getByRole('button', { name: 'action-button-validate-bom' })
.click();
await page.getByRole('button', { name: 'Submit' }).click();
// Background task monitoring
await page.getByText('Validating BOM').waitFor();
await page.getByText('BOM validated').waitFor();
await page.getByRole('button', { name: 'bom-validation-info' }).hover();
await page.getByText('Validated By: allaccessAlly').waitFor();
// Edit line item, to ensure BOM is not valid next time around
// Edit line item, to ensure BOM is not valid
const cell = await page.getByRole('cell', { name: 'Red paint Red Paint' });
await clickOnRowMenu(cell);
await page.getByRole('menuitem', { name: 'Edit', exact: true }).click();
@@ -176,6 +163,22 @@ test('Parts - BOM Validation', async ({ browser }) => {
await input.fill(`${nextValue.toFixed(3)}`);
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('BOM item updated').waitFor();
await loadTab(page, 'Part Details');
await loadTab(page, 'Bill of Materials');
// Run BOM validation step
await page
.getByRole('button', { name: 'action-button-validate-bom' })
.click();
await page.getByRole('button', { name: 'Submit' }).click();
// Background task monitoring
await page.getByText('Validating BOM').waitFor();
await page.getByText('BOM validated').waitFor();
await page.getByRole('button', { name: 'bom-validation-info' }).hover();
await page.getByText('Validated By: allaccessAlly').waitFor();
});
test('Parts - Editing', async ({ browser }) => {
@@ -313,10 +316,9 @@ test('Parts - Requirements', async ({ browser }) => {
// Also check requirements for part with open build orders which have been partially consumed
await navigate(page, 'part/105/details');
await page.getByText('Required: 2').waitFor();
await page.getByText('Available: 32').waitFor();
await page.getByText('In Stock: 34').waitFor();
await page.getByText('2 / 2').waitFor(); // Allocated to build orders
await page.getByText(/Required: \d+/).waitFor();
});
test('Parts - Allocations', async ({ browser }) => {

View File

@@ -442,6 +442,22 @@ test('Purchase Orders - Receive Items', async ({ browser }) => {
await navigate(page, 'purchasing/purchase-order/2/line-items');
const cell = await page.getByText('Red Paint', { exact: true });
// First, ensure that the row has sufficient quantity to receive
// This is required to ensure the robustness of this test,
// as the test data may be modified by other tests
await clickOnRowMenu(cell);
await page.getByRole('menuitem', { name: 'Edit' }).click();
const quantityInput = await page.getByRole('textbox', {
name: 'number-field-quantity'
});
const quantity = Number.parseInt(await quantityInput.inputValue());
await quantityInput.fill((quantity + 100).toString());
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Item Updated').waitFor();
// Now, receive the items
await clickOnRowMenu(cell);
await page.getByRole('menuitem', { name: 'Receive line item' }).click();
@@ -451,6 +467,7 @@ test('Purchase Orders - Receive Items', async ({ browser }) => {
// Receive only a *single* item
await page.getByLabel('number-field-quantity').fill('1');
await page.waitForTimeout(500);
// Assign custom information
await page.getByLabel('action-button-assign-batch-').click();
@@ -477,6 +494,9 @@ test('Purchase Orders - Receive Items', async ({ browser }) => {
await loadTab(page, 'Received Stock');
await clearTableFilters(page);
await page
.getByRole('textbox', { name: 'table-search-input' })
.fill('my-batch-code');
await page.getByRole('cell', { name: 'my-batch-code' }).first().waitFor();
});

View File

@@ -94,7 +94,7 @@ test('Importing - BOM', async ({ browser }) => {
await page.getByRole('button', { name: 'Accept Column Mapping' }).click();
await page.waitForTimeout(500);
await page.getByText('Importing Data').waitFor();
await page.getByText('Importing Data').first().waitFor();
await page.getByText('0 / 3').waitFor();
await page.getByText('Screw for fixing wood').first().waitFor();