2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-04-03 01:51:08 +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 information."""
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ 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 v464 -> 2026-03-15 : https://github.com/inventree/InvenTree/pull/11527
- Add API endpoint for monitoring the progress of a particular background task - 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 django_filters.rest_framework.filterset import FilterSet
from drf_spectacular.utils import extend_schema, extend_schema_field from drf_spectacular.utils import extend_schema, extend_schema_field
from rest_framework import serializers, status 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 from rest_framework.response import Response
import build.models as build_models import build.models as build_models
@@ -662,6 +662,13 @@ class BuildLineDetail(BuildLineMixin, OutputOptionsMixin, RetrieveUpdateDestroyA
class BuildOrderContextMixin: class BuildOrderContextMixin:
"""Mixin class which adds build order as serializer context variable.""" """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): def get_serializer_context(self):
"""Add extra context information to the endpoint serializer.""" """Add extra context information to the endpoint serializer."""
ctx = super().get_serializer_context() ctx = super().get_serializer_context()
@@ -670,8 +677,8 @@ class BuildOrderContextMixin:
ctx['to_complete'] = True ctx['to_complete'] = True
try: try:
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None)) ctx['build'] = self.get_build()
except Exception: except NotFound:
pass pass
return ctx return ctx
@@ -764,6 +771,37 @@ class BuildAutoAllocate(BuildOrderContextMixin, CreateAPI):
queryset = Build.objects.none() queryset = Build.objects.none()
serializer_class = build.serializers.BuildAutoAllocationSerializer 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): class BuildAllocate(BuildOrderContextMixin, CreateAPI):
"""API endpoint to allocate stock items to a build order. """API endpoint to allocate stock items to a build order.
@@ -786,6 +824,39 @@ class BuildConsume(BuildOrderContextMixin, CreateAPI):
queryset = Build.objects.none() queryset = Build.objects.none()
serializer_class = build.serializers.BuildConsumeSerializer 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): class BuildIssue(BuildOrderContextMixin, CreateAPI):
"""API endpoint for issuing a BuildOrder.""" """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 import serializers
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
import build.tasks
import common.filters import common.filters
import common.settings import common.settings
import company.serializers import company.serializers
@@ -38,7 +37,6 @@ from InvenTree.serializers import (
NotesFieldMixin, NotesFieldMixin,
enable_filter, enable_filter,
) )
from InvenTree.tasks import offload_task
from stock.generators import generate_batch_code from stock.generators import generate_batch_code
from stock.models import StockItem, StockLocation from stock.models import StockItem, StockLocation
from stock.serializers import ( from stock.serializers import (
@@ -51,7 +49,6 @@ from users.serializers import OwnerSerializer, UserSerializer
from .models import Build, BuildItem, BuildLine from .models import Build, BuildItem, BuildLine
from .status_codes import BuildStatus from .status_codes import BuildStatus
from .tasks import consume_build_item, consume_build_line
class BuildSerializer( class BuildSerializer(
@@ -1129,27 +1126,6 @@ class BuildAutoAllocationSerializer(serializers.Serializer):
help_text=_('Select item type to auto-allocate'), 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( class BuildItemSerializer(
FilterableSerializerMixin, DataImportExportSerializerMixin, InvenTreeModelSerializer FilterableSerializerMixin, DataImportExportSerializerMixin, InvenTreeModelSerializer
@@ -1847,46 +1823,3 @@ class BuildConsumeSerializer(serializers.Serializer):
raise ValidationError(_('At least one item or line must be provided')) raise ValidationError(_('At least one item or line must be provided'))
return data 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 datetime import timedelta
from decimal import Decimal from decimal import Decimal
from typing import Optional
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import transaction
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import structlog import structlog
@@ -27,61 +29,53 @@ def auto_allocate_build(build_id: int, **kwargs):
"""Run auto-allocation for a specified BuildOrder.""" """Run auto-allocation for a specified BuildOrder."""
from build.models import Build from build.models import Build
build_order = Build.objects.filter(pk=build_id).first() build_order = Build.objects.get(pk=build_id)
if not build_order:
logger.warning(
'Could not auto-allocate BuildOrder <%s> - BuildOrder does not exist',
build_id,
)
return
build_order.auto_allocate_stock(**kwargs) build_order.auto_allocate_stock(**kwargs)
@tracer.start_as_current_span('consume_build_item') @tracer.start_as_current_span('consume_build_stock')
def consume_build_item( def consume_build_stock(
item_id: str, quantity, notes: str = '', user_id: int | None = None 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.""" """Consume stock for the specified BuildOrder.
from build.models import BuildItem
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: build = Build.objects.get(pk=build_id)
logger.warning( user = User.objects.filter(pk=user_id).first() if user_id else None
'Could not consume stock for BuildItem <%s> - BuildItem does not exist',
item_id,
)
return
item.complete_allocation( lines = lines or []
quantity=quantity, items = items or {}
notes=notes, notes = kwargs.pop('notes', '')
user=User.objects.filter(pk=user_id).first() if user_id else None,
)
# 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') # Consume each of the specified BuildItem objects
def consume_build_line(line_id: int, notes: str = '', user_id: int | None = None): for item_id, quantity in items.items():
"""Consume stock against a particular BuildOrderLineItem.""" if build_item := BuildItem.objects.filter(
from build.models import BuildLine pk=item_id, build_line__build=build
).first():
line_item = BuildLine.objects.filter(pk=line_id).first() build_item.complete_allocation(
quantity=quantity, notes=notes, user=user
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,
)
@tracer.start_as_current_span('complete_build_allocations') @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}) url = reverse('api-build-auto-allocate', kwargs={'pk': build.pk})
# Allocate only 'untracked' items - this should not allocate our tracked item # 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()) self.assertEqual(N, BuildItem.objects.count())
# Allocate 'tracked' items - this should allocate our tracked item # 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 # A new BuildItem should have been created
self.assertEqual(N + 1, BuildItem.objects.count()) 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()] '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.allocated_stock.count(), 0)
self.assertEqual(self.build.consumed_stock.count(), 3) 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.allocated_stock.count(), 0)
self.assertEqual(self.build.consumed_stock.count(), 3) self.assertEqual(self.build.consumed_stock.count(), 3)

View File

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

View File

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

View File

@@ -31,6 +31,7 @@ import {
useBuildOrderFields, useBuildOrderFields,
useConsumeBuildLinesForm useConsumeBuildLinesForm
} from '../../forms/BuildForms'; } from '../../forms/BuildForms';
import useBackgroundTask from '../../hooks/UseBackgroundTask';
import { import {
useCreateApiFormModal, useCreateApiFormModal,
useDeleteApiFormModal, useDeleteApiFormModal,
@@ -569,6 +570,17 @@ export default function BuildLineTable({
modelType: ModelType.build 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({ const autoAllocateStock = useCreateApiFormModal({
url: ApiEndpoints.build_order_auto_allocate, url: ApiEndpoints.build_order_auto_allocate,
pk: build.pk, pk: build.pk,
@@ -582,8 +594,10 @@ export default function BuildLineTable({
substitutes: true, substitutes: true,
optional_items: false optional_items: false
}, },
successMessage: t`Auto allocation in progress`, successMessage: null,
table: table, onFormSuccess: (response: any) => {
setAllocateTaskId(response.task_id);
},
preFormContent: ( preFormContent: (
<Alert color='green' title={t`Auto Allocate Stock`}> <Alert color='green' title={t`Auto Allocate Stock`}>
<Text>{t`Automatically allocate untracked BOM items to this build according to the selected options`}</Text> <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 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({ const consumeLines = useConsumeBuildLinesForm({
buildId: build.pk, buildId: build.pk,
buildLines: selectedRows, buildLines: selectedRows,
onFormSuccess: () => { onFormSuccess: (response: any) => {
table.clearSelectedRecords(); table.clearSelectedRecords();
table.refreshTable();
if (response.task_id) {
setConsumeTaskId(response.task_id);
} else {
table.refreshTable();
}
} }
}); });

View File

@@ -45,6 +45,7 @@ import {
useStockItemSerializeFields useStockItemSerializeFields
} from '../../forms/StockForms'; } from '../../forms/StockForms';
import { InvenTreeIcon } from '../../functions/icons'; import { InvenTreeIcon } from '../../functions/icons';
import useBackgroundTask from '../../hooks/UseBackgroundTask';
import { import {
useCreateApiFormModal, useCreateApiFormModal,
useEditApiFormModal 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({ const autoAllocateStock = useCreateApiFormModal({
url: ApiEndpoints.build_order_auto_allocate, url: ApiEndpoints.build_order_auto_allocate,
pk: build.pk, pk: build.pk,
@@ -226,12 +238,9 @@ export default function BuildOutputTable({
location: build.take_from, location: build.take_from,
substitutes: true substitutes: true
}, },
successMessage: t`Auto-allocation in progress`, successMessage: null,
onFormSuccess: () => { onFormSuccess: (response: any) => {
// After a short delay, refresh the tracked items setAllocateTaskId(response.task_id);
setTimeout(() => {
refetchTrackedItems();
}, 2500);
}, },
table: table, table: table,
preFormContent: ( preFormContent: (

View File

@@ -219,6 +219,7 @@ test('Build Order - Build Outputs', async ({ browser }) => {
await clearTableFilters(page); await clearTableFilters(page);
// We have now loaded the "Build Order" table. Check for some expected texts // 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('On Hold').first().waitFor();
await page.getByText('Pending').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(); await page.getByText('Build outputs have been completed').waitFor();
// Check for expected UI elements in the "scrap output" dialog // 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); const row3 = await getRowFromCell(cell3);
await row3.getByLabel(/row-action-menu-/i).click(); await row3.getByLabel(/row-action-menu-/i).click();
await page.getByRole('menuitem', { name: 'Scrap' }).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 partial stock consumption against build order
test('Build Order - Consume Stock', async ({ browser }) => { test('Build Order - Consume Stock', async ({ browser }) => {
const page = await doCachedLogin(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 // Duplicate this build order, to ensure a fresh run each time
await page.getByText('2 / 2', { exact: true }).waitFor(); await page.getByRole('button', { name: 'action-menu-build-order-' }).click();
await page.getByText('8 / 10', { exact: true }).waitFor(); await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await page.getByText('5 / 35', { exact: true }).waitFor(); await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('5 / 40', { exact: true }).waitFor(); await page.getByText('Item Created').waitFor();
// Open the "Allocate Stock" dialog // Issue the order
await page.getByRole('checkbox', { name: 'Select all records' }).check(); 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 await page
.getByRole('button', { name: 'action-button-allocate-stock' }) .getByRole('button', { name: 'action-button-auto-allocate-' })
.click(); .click();
await page await page.getByRole('button', { name: 'Submit' }).click();
.getByLabel('Allocate Stock')
.getByText('5 / 35', { exact: true })
.waitFor();
await page.getByRole('button', { name: 'Cancel' }).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 await page
.getByRole('button', { name: 'action-button-consume-stock' }) .getByRole('button', { name: 'action-button-consume-stock' })
.click(); .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 await page
.getByRole('textbox', { name: 'text-field-notes', exact: true }) .getByRole('textbox', { name: 'text-field-notes' })
.fill('some notes here...'); .fill('consuming a single item');
await page.getByRole('button', { name: 'Cancel' }).click(); await page.waitForTimeout(250);
await page.getByRole('button', { name: 'Submit' }).click();
// Try with a different build order // Confirm progress and success
await navigate(page, 'manufacturing/build-order/26/line-items'); 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('checkbox', { name: 'Select all records' }).check();
await page await page
.getByRole('button', { name: 'action-button-consume-stock' }) .getByRole('button', { name: 'action-button-consume-stock' })
.click(); .click();
await page.getByLabel('Consume Stock').getByText('306 / 1,900').waitFor();
await page await page
.getByLabel('Consume Stock') .getByRole('textbox', { name: 'text-field-notes' })
.getByText('Fully consumed') .fill('consuming remaining items');
.first() await page.waitForTimeout(250);
.waitFor(); 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 }) => { 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' url: 'manufacturing/build-order/10/incomplete-outputs'
}); });
const cancelBuildOutput = async (cell) => { const cancelBuildOutput = async (cell: any) => {
await clickOnRowMenu(cell); await clickOnRowMenu(cell);
await page.getByRole('menuitem', { name: 'Cancel' }).click(); await page.getByRole('menuitem', { name: 'Cancel' }).click();
await page.getByRole('button', { name: 'Submit', exact: true }).click(); await page.getByRole('button', { name: 'Submit', exact: true }).click();
@@ -633,9 +649,11 @@ test('Build Order - Filters', async ({ browser }) => {
// Toggle 'Outstanding' filter // Toggle 'Outstanding' filter
await setTableChoiceFilter(page, 'Outstanding', 'Yes'); await setTableChoiceFilter(page, 'Outstanding', 'Yes');
await page.getByRole('textbox', { name: 'table-search-input' }).fill('1');
await page.getByRole('cell', { name: 'BO0017' }).waitFor(); await page.getByRole('cell', { name: 'BO0017' }).waitFor();
await clearTableFilters(page); await clearTableFilters(page);
await page.getByRole('textbox', { name: 'table-search-input' }).fill('');
await setTableChoiceFilter(page, 'Outstanding', 'No'); await setTableChoiceFilter(page, 'Outstanding', 'No');
await page.getByText('1 - 6 / 6').waitFor(); await page.getByText('1 - 6 / 6').waitFor();

View File

@@ -147,20 +147,7 @@ test('Parts - BOM', async ({ browser }) => {
test('Parts - BOM Validation', async ({ browser }) => { test('Parts - BOM Validation', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'part/107/bom' }); const page = await doCachedLogin(browser, { url: 'part/107/bom' });
// Run BOM validation step // Edit line item, to ensure BOM is not valid
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
const cell = await page.getByRole('cell', { name: 'Red paint Red Paint' }); const cell = await page.getByRole('cell', { name: 'Red paint Red Paint' });
await clickOnRowMenu(cell); await clickOnRowMenu(cell);
await page.getByRole('menuitem', { name: 'Edit', exact: true }).click(); 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 input.fill(`${nextValue.toFixed(3)}`);
await page.getByRole('button', { name: 'Submit' }).click(); await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('BOM item updated').waitFor(); 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 }) => { 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 // Also check requirements for part with open build orders which have been partially consumed
await navigate(page, 'part/105/details'); await navigate(page, 'part/105/details');
await page.getByText('Required: 2').waitFor();
await page.getByText('Available: 32').waitFor(); await page.getByText('Available: 32').waitFor();
await page.getByText('In Stock: 34').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 }) => { 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'); await navigate(page, 'purchasing/purchase-order/2/line-items');
const cell = await page.getByText('Red Paint', { exact: true }); 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 clickOnRowMenu(cell);
await page.getByRole('menuitem', { name: 'Receive line item' }).click(); 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 // Receive only a *single* item
await page.getByLabel('number-field-quantity').fill('1'); await page.getByLabel('number-field-quantity').fill('1');
await page.waitForTimeout(500);
// Assign custom information // Assign custom information
await page.getByLabel('action-button-assign-batch-').click(); 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 loadTab(page, 'Received Stock');
await clearTableFilters(page); 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(); 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.getByRole('button', { name: 'Accept Column Mapping' }).click();
await page.waitForTimeout(500); 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('0 / 3').waitFor();
await page.getByText('Screw for fixing wood').first().waitFor(); await page.getByText('Screw for fixing wood').first().waitFor();