From 84cd81d9a8cde19c5d94cf05d52163af4e9dfa0f Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 17 Mar 2026 20:51:12 +1100 Subject: [PATCH] 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 --- .../InvenTree/InvenTree/api_version.py | 6 +- src/backend/InvenTree/build/api.py | 77 ++++++++++++++++- src/backend/InvenTree/build/serializers.py | 67 --------------- src/backend/InvenTree/build/tasks.py | 86 +++++++++---------- src/backend/InvenTree/build/test_api.py | 8 +- src/frontend/src/forms/BuildForms.tsx | 4 +- .../tables/build/BuildAllocatedStockTable.tsx | 21 ++++- .../src/tables/build/BuildLineTable.tsx | 38 +++++++- .../src/tables/build/BuildOutputTable.tsx | 21 +++-- src/frontend/tests/pages/pui_build.spec.ts | 84 +++++++++++------- src/frontend/tests/pages/pui_part.spec.ts | 34 ++++---- .../tests/pages/pui_purchase_order.spec.ts | 20 +++++ src/frontend/tests/pui_importing.spec.ts | 2 +- 13 files changed, 283 insertions(+), 185 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 3525ce8a6a..245754ba28 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index f509b980b4..36ebbd15b0 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -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.""" diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index 390858bbeb..1e1f3c6860 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -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, - ) diff --git a/src/backend/InvenTree/build/tasks.py b/src/backend/InvenTree/build/tasks.py index fd8e3da1de..b785ff266f 100644 --- a/src/backend/InvenTree/build/tasks.py +++ b/src/backend/InvenTree/build/tasks.py @@ -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') diff --git a/src/backend/InvenTree/build/test_api.py b/src/backend/InvenTree/build/test_api.py index 7dacc2ed85..da9682fe00 100644 --- a/src/backend/InvenTree/build/test_api.py +++ b/src/backend/InvenTree/build/test_api.py @@ -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) diff --git a/src/frontend/src/forms/BuildForms.tsx b/src/frontend/src/forms/BuildForms.tsx index 855813fce8..ca17b8c1c2 100644 --- a/src/frontend/src/forms/BuildForms.tsx +++ b/src/frontend/src/forms/BuildForms.tsx @@ -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: { diff --git a/src/frontend/src/tables/build/BuildAllocatedStockTable.tsx b/src/frontend/src/tables/build/BuildAllocatedStockTable.tsx index 9932f64e16..c4df80880b 100644 --- a/src/frontend/src/tables/build/BuildAllocatedStockTable.tsx +++ b/src/frontend/src/tables/build/BuildAllocatedStockTable.tsx @@ -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(''); + + 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(); + } } }); diff --git a/src/frontend/src/tables/build/BuildLineTable.tsx b/src/frontend/src/tables/build/BuildLineTable.tsx index d072df911b..559303666a 100644 --- a/src/frontend/src/tables/build/BuildLineTable.tsx +++ b/src/frontend/src/tables/build/BuildLineTable.tsx @@ -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(''); + + 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: ( {t`Automatically allocate untracked BOM items to this build according to the selected options`} @@ -669,12 +683,28 @@ export default function BuildLineTable({ parts: partsToOrder }); + const [consumeTaskId, setConsumeTaskId] = useState(''); + + 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(); + } } }); diff --git a/src/frontend/src/tables/build/BuildOutputTable.tsx b/src/frontend/src/tables/build/BuildOutputTable.tsx index 740ce77923..f02c28e92a 100644 --- a/src/frontend/src/tables/build/BuildOutputTable.tsx +++ b/src/frontend/src/tables/build/BuildOutputTable.tsx @@ -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(''); + + 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: ( diff --git a/src/frontend/tests/pages/pui_build.spec.ts b/src/frontend/tests/pages/pui_build.spec.ts index e929c2d80d..7f4e8fde81 100644 --- a/src/frontend/tests/pages/pui_build.spec.ts +++ b/src/frontend/tests/pages/pui_build.spec.ts @@ -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(); diff --git a/src/frontend/tests/pages/pui_part.spec.ts b/src/frontend/tests/pages/pui_part.spec.ts index c222cf4bbb..368e4ebbac 100644 --- a/src/frontend/tests/pages/pui_part.spec.ts +++ b/src/frontend/tests/pages/pui_part.spec.ts @@ -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 }) => { diff --git a/src/frontend/tests/pages/pui_purchase_order.spec.ts b/src/frontend/tests/pages/pui_purchase_order.spec.ts index 4b047d3eac..0823be6689 100644 --- a/src/frontend/tests/pages/pui_purchase_order.spec.ts +++ b/src/frontend/tests/pages/pui_purchase_order.spec.ts @@ -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(); }); diff --git a/src/frontend/tests/pui_importing.spec.ts b/src/frontend/tests/pui_importing.spec.ts index 022de8e681..7ecc62dd29 100644 --- a/src/frontend/tests/pui_importing.spec.ts +++ b/src/frontend/tests/pui_importing.spec.ts @@ -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();