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