mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-15 00:38:12 +00:00
Build order consume (#8191)
* Adds "consumed" field to BuildLine model * Expose new field to serializer * Add "consumed" column to BuildLineTable * Boolean column tweaks * Increase consumed count when completing allocation * Add comment * Update migration * Add serializer for consuming build items * Improve build-line sub-table * Refactor BuildItem.complete_allocation method - Allow optional quantity to be specified - Adjust the allocated quantity when consuming * Perform consumption * Add "BuildConsume" API endpoint * Implement frontend form * Fixes for serializer * Enhance front-end form * Fix rendering of BuildLineTable * Further improve rendering * Bump API version * Update API description * Add option to consume by specifying a list of BuildLine objects * Add form to consume stock via BuildLine reference * Fix api_version * Fix backup colors * Fix typo * Fix migrations * Fix build forms * Forms fixes * Fix formatting * Fixes for BuildLineTable * Account for consumed stock in requirements calculation * Reduce API requirements for BuildLineTable * Docs updates * Updated playwright testing * Update src/frontend/src/forms/BuildForms.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/frontend/src/tables/build/BuildLineTable.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add unit test for filters * Add functional tests * Tweak query count * Increase max query time for testing * adjust unit test again * Prevent consumption of "tracked" items * Adjust playwright tests * Fix table * Fix rendering --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,11 +1,15 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 385
|
||||
INVENTREE_API_VERSION = 386
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
v386 -> 2025-08-11 : https://github.com/inventree/InvenTree/pull/8191
|
||||
- Adds "consumed" field to the BuildItem API
|
||||
- Adds API endpoint to consume stock against a BuildOrder
|
||||
|
||||
v385 -> 2025-08-15 : https://github.com/inventree/InvenTree/pull/10174
|
||||
- Adjust return type of PurchaseOrderReceive API serializer
|
||||
- Now returns list of of the created stock items when receiving
|
||||
|
||||
@@ -479,6 +479,14 @@ class BuildLineFilter(rest_filters.FilterSet):
|
||||
return queryset.filter(allocated__gte=F('quantity'))
|
||||
return queryset.filter(allocated__lt=F('quantity'))
|
||||
|
||||
consumed = rest_filters.BooleanFilter(label=_('Consumed'), method='filter_consumed')
|
||||
|
||||
def filter_consumed(self, queryset, name, value):
|
||||
"""Filter by whether each BuildLine is fully consumed."""
|
||||
if str2bool(value):
|
||||
return queryset.filter(consumed__gte=F('quantity'))
|
||||
return queryset.filter(consumed__lt=F('quantity'))
|
||||
|
||||
available = rest_filters.BooleanFilter(
|
||||
label=_('Available'), method='filter_available'
|
||||
)
|
||||
@@ -494,6 +502,7 @@ class BuildLineFilter(rest_filters.FilterSet):
|
||||
"""
|
||||
flt = Q(
|
||||
quantity__lte=F('allocated')
|
||||
+ F('consumed')
|
||||
+ F('available_stock')
|
||||
+ F('available_substitute_stock')
|
||||
+ F('available_variant_stock')
|
||||
@@ -504,7 +513,7 @@ class BuildLineFilter(rest_filters.FilterSet):
|
||||
return queryset.exclude(flt)
|
||||
|
||||
|
||||
class BuildLineEndpoint:
|
||||
class BuildLineMixin:
|
||||
"""Mixin class for BuildLine API endpoints."""
|
||||
|
||||
queryset = BuildLine.objects.all()
|
||||
@@ -553,7 +562,7 @@ class BuildLineEndpoint:
|
||||
)
|
||||
|
||||
|
||||
class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI):
|
||||
class BuildLineList(BuildLineMixin, DataExportViewMixin, ListCreateAPI):
|
||||
"""API endpoint for accessing a list of BuildLine objects."""
|
||||
|
||||
filterset_class = BuildLineFilter
|
||||
@@ -562,6 +571,7 @@ class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI):
|
||||
ordering_fields = [
|
||||
'part',
|
||||
'allocated',
|
||||
'consumed',
|
||||
'reference',
|
||||
'quantity',
|
||||
'consumable',
|
||||
@@ -605,7 +615,7 @@ class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI):
|
||||
return source_build
|
||||
|
||||
|
||||
class BuildLineDetail(BuildLineEndpoint, RetrieveUpdateDestroyAPI):
|
||||
class BuildLineDetail(BuildLineMixin, RetrieveUpdateDestroyAPI):
|
||||
"""API endpoint for detail view of a BuildLine object."""
|
||||
|
||||
def get_source_build(self) -> Build | None:
|
||||
@@ -734,6 +744,13 @@ class BuildAllocate(BuildOrderContextMixin, CreateAPI):
|
||||
serializer_class = build.serializers.BuildAllocationSerializer
|
||||
|
||||
|
||||
class BuildConsume(BuildOrderContextMixin, CreateAPI):
|
||||
"""API endpoint to consume stock against a build order."""
|
||||
|
||||
queryset = Build.objects.none()
|
||||
serializer_class = build.serializers.BuildConsumeSerializer
|
||||
|
||||
|
||||
class BuildIssue(BuildOrderContextMixin, CreateAPI):
|
||||
"""API endpoint for issuing a BuildOrder."""
|
||||
|
||||
@@ -953,6 +970,7 @@ build_api_urls = [
|
||||
'<int:pk>/',
|
||||
include([
|
||||
path('allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
|
||||
path('consume/', BuildConsume.as_view(), name='api-build-consume'),
|
||||
path(
|
||||
'auto-allocate/',
|
||||
BuildAutoAllocate.as_view(),
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 4.2.15 on 2024-09-26 10:11
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('build', '0057_build_external'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='buildline',
|
||||
name='consumed',
|
||||
field=models.DecimalField(decimal_places=5, default=0, help_text='Quantity of consumed stock', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Consumed'),
|
||||
),
|
||||
]
|
||||
@@ -1106,7 +1106,7 @@ class Build(
|
||||
|
||||
# Remove stock
|
||||
for item in items:
|
||||
item.complete_allocation(user)
|
||||
item.complete_allocation(user=user)
|
||||
|
||||
# Delete allocation
|
||||
items.all().delete()
|
||||
@@ -1151,7 +1151,7 @@ class Build(
|
||||
# Complete or discard allocations
|
||||
for build_item in allocated_items:
|
||||
if not discard_allocations:
|
||||
build_item.complete_allocation(user)
|
||||
build_item.complete_allocation(user=user)
|
||||
|
||||
# Delete allocations
|
||||
allocated_items.delete()
|
||||
@@ -1200,7 +1200,7 @@ class Build(
|
||||
|
||||
for build_item in allocated_items:
|
||||
# Complete the allocation of stock for that item
|
||||
build_item.complete_allocation(user)
|
||||
build_item.complete_allocation(user=user)
|
||||
|
||||
# Delete the BuildItem objects from the database
|
||||
allocated_items.all().delete()
|
||||
@@ -1569,6 +1569,7 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo
|
||||
build: Link to a Build object
|
||||
bom_item: Link to a BomItem object
|
||||
quantity: Number of units required for the Build
|
||||
consumed: Number of units which have been consumed against this line item
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
@@ -1614,6 +1615,15 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo
|
||||
help_text=_('Required quantity for build order'),
|
||||
)
|
||||
|
||||
consumed = models.DecimalField(
|
||||
decimal_places=5,
|
||||
max_digits=15,
|
||||
default=0,
|
||||
validators=[MinValueValidator(0)],
|
||||
verbose_name=_('Consumed'),
|
||||
help_text=_('Quantity of consumed stock'),
|
||||
)
|
||||
|
||||
@property
|
||||
def part(self):
|
||||
"""Return the sub_part reference from the link bom_item."""
|
||||
@@ -1645,6 +1655,10 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo
|
||||
"""Return True if this BuildLine is over-allocated."""
|
||||
return self.allocated_quantity() > self.quantity
|
||||
|
||||
def is_fully_consumed(self):
|
||||
"""Return True if this BuildLine is fully consumed."""
|
||||
return self.consumed >= self.quantity
|
||||
|
||||
|
||||
class BuildItem(InvenTree.models.InvenTreeMetadataModel):
|
||||
"""A BuildItem links multiple StockItem objects to a Build.
|
||||
@@ -1812,20 +1826,36 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel):
|
||||
return self.build_line.bom_item if self.build_line else None
|
||||
|
||||
@transaction.atomic
|
||||
def complete_allocation(self, user, notes=''):
|
||||
def complete_allocation(self, quantity=None, notes='', user=None):
|
||||
"""Complete the allocation of this BuildItem into the output stock item.
|
||||
|
||||
Arguments:
|
||||
quantity: The quantity to allocate (default is the full quantity)
|
||||
notes: Additional notes to add to the transaction
|
||||
user: The user completing the allocation
|
||||
|
||||
- If the referenced part is trackable, the stock item will be *installed* into the build output
|
||||
- If the referenced part is *not* trackable, the stock item will be *consumed* by the build order
|
||||
|
||||
TODO: This is quite expensive (in terms of number of database hits) - and requires some thought
|
||||
TODO: Revisit, and refactor!
|
||||
|
||||
"""
|
||||
# If the quantity is not provided, use the quantity of this BuildItem
|
||||
if quantity is None:
|
||||
quantity = self.quantity
|
||||
|
||||
item = self.stock_item
|
||||
|
||||
# Ensure we are not allocating more than available
|
||||
if quantity > item.quantity:
|
||||
raise ValidationError({
|
||||
'quantity': _('Allocated quantity exceeds available stock quantity')
|
||||
})
|
||||
|
||||
# Split the allocated stock if there are more available than allocated
|
||||
if item.quantity > self.quantity:
|
||||
item = item.splitStock(self.quantity, None, user, notes=notes)
|
||||
if item.quantity > quantity:
|
||||
item = item.splitStock(quantity, None, user, notes=notes)
|
||||
|
||||
# For a trackable part, special consideration needed!
|
||||
if item.part.trackable:
|
||||
@@ -1835,7 +1865,7 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel):
|
||||
|
||||
# Install the stock item into the output
|
||||
self.install_into.installStockItem(
|
||||
item, self.quantity, user, notes, build=self.build
|
||||
item, quantity, user, notes, build=self.build
|
||||
)
|
||||
|
||||
else:
|
||||
@@ -1851,6 +1881,18 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel):
|
||||
deltas={'buildorder': self.build.pk, 'quantity': float(item.quantity)},
|
||||
)
|
||||
|
||||
# Increase the "consumed" count for the associated BuildLine
|
||||
self.build_line.consumed += quantity
|
||||
self.build_line.save()
|
||||
|
||||
# Decrease the allocated quantity
|
||||
self.quantity = max(0, self.quantity - quantity)
|
||||
|
||||
if self.quantity <= 0:
|
||||
self.delete()
|
||||
else:
|
||||
self.save()
|
||||
|
||||
build_line = models.ForeignKey(
|
||||
BuildLine, on_delete=models.CASCADE, null=True, related_name='allocations'
|
||||
)
|
||||
|
||||
@@ -171,6 +171,9 @@ class BuildSerializer(
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Determine if extra serializer fields are required."""
|
||||
part_detail = kwargs.pop('part_detail', True)
|
||||
user_detail = kwargs.pop('user_detail', True)
|
||||
project_code_detail = kwargs.pop('project_code_detail', True)
|
||||
|
||||
kwargs.pop('create', False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -181,6 +184,15 @@ class BuildSerializer(
|
||||
if not part_detail:
|
||||
self.fields.pop('part_detail', None)
|
||||
|
||||
if not user_detail:
|
||||
self.fields.pop('issued_by_detail', None)
|
||||
self.fields.pop('responsible_detail', None)
|
||||
|
||||
if not project_code_detail:
|
||||
self.fields.pop('project_code', None)
|
||||
self.fields.pop('project_code_label', None)
|
||||
self.fields.pop('project_code_detail', None)
|
||||
|
||||
def validate_reference(self, reference):
|
||||
"""Custom validation for the Build reference field."""
|
||||
# Ensure the reference matches the required pattern
|
||||
@@ -1000,7 +1012,7 @@ class BuildAllocationItemSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class BuildAllocationSerializer(serializers.Serializer):
|
||||
"""DRF serializer for allocation stock items against a build order."""
|
||||
"""Serializer for allocating stock items against a build order."""
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass."""
|
||||
@@ -1302,6 +1314,8 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
||||
'build',
|
||||
'bom_item',
|
||||
'quantity',
|
||||
'consumed',
|
||||
'allocations',
|
||||
'part',
|
||||
# Build detail fields
|
||||
'build_reference',
|
||||
@@ -1353,6 +1367,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
||||
|
||||
if not part_detail:
|
||||
self.fields.pop('part_detail', None)
|
||||
self.fields.pop('part_category_name', None)
|
||||
|
||||
if not build_detail:
|
||||
self.fields.pop('build_detail', None)
|
||||
@@ -1379,7 +1394,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
||||
read_only=True,
|
||||
)
|
||||
|
||||
allocations = BuildItemSerializer(many=True, read_only=True)
|
||||
allocations = BuildItemSerializer(many=True, read_only=True, build_detail=False)
|
||||
|
||||
# BOM item info fields
|
||||
reference = serializers.CharField(
|
||||
@@ -1405,6 +1420,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
||||
)
|
||||
|
||||
quantity = serializers.FloatField(label=_('Quantity'))
|
||||
consumed = serializers.FloatField(label=_('Consumed'))
|
||||
|
||||
bom_item = serializers.PrimaryKeyRelatedField(label=_('BOM Item'), read_only=True)
|
||||
|
||||
@@ -1437,17 +1453,23 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
||||
read_only=True,
|
||||
pricing=False,
|
||||
)
|
||||
|
||||
build_detail = BuildSerializer(
|
||||
label=_('Build'),
|
||||
source='build',
|
||||
part_detail=False,
|
||||
many=False,
|
||||
read_only=True,
|
||||
allow_null=True,
|
||||
part_detail=False,
|
||||
user_detail=False,
|
||||
project_code_detail=False,
|
||||
)
|
||||
|
||||
# Annotated (calculated) fields
|
||||
allocated = serializers.FloatField(label=_('Allocated Stock'), read_only=True)
|
||||
|
||||
# Total quantity of allocated stock
|
||||
allocated = serializers.FloatField(label=_('Allocated'), read_only=True)
|
||||
|
||||
on_order = serializers.FloatField(label=_('On Order'), read_only=True)
|
||||
in_production = serializers.FloatField(label=_('In Production'), read_only=True)
|
||||
scheduled_to_build = serializers.FloatField(
|
||||
@@ -1498,6 +1520,9 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
||||
'allocations__stock_item',
|
||||
'allocations__stock_item__part',
|
||||
'allocations__stock_item__location',
|
||||
'bom_item',
|
||||
'bom_item__part',
|
||||
'bom_item__sub_part',
|
||||
'bom_item__sub_part__stock_items',
|
||||
'bom_item__sub_part__stock_items__allocations',
|
||||
'bom_item__sub_part__stock_items__sales_order_allocations',
|
||||
@@ -1518,7 +1543,6 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
||||
'build__destination',
|
||||
'build__take_from',
|
||||
'build__completed_by',
|
||||
'build__issued_by',
|
||||
'build__sales_order',
|
||||
'build__parent',
|
||||
'build__notes',
|
||||
@@ -1692,3 +1716,177 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class BuildConsumeAllocationSerializer(serializers.Serializer):
|
||||
"""Serializer for an individual BuildItem to be consumed against a BuildOrder."""
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass."""
|
||||
|
||||
fields = ['build_item', 'quantity']
|
||||
|
||||
build_item = serializers.PrimaryKeyRelatedField(
|
||||
queryset=BuildItem.objects.all(), many=False, allow_null=False, required=True
|
||||
)
|
||||
|
||||
quantity = serializers.DecimalField(
|
||||
max_digits=15, decimal_places=5, min_value=Decimal(0), required=True
|
||||
)
|
||||
|
||||
def validate_quantity(self, quantity):
|
||||
"""Perform validation on the 'quantity' field."""
|
||||
if quantity <= 0:
|
||||
raise ValidationError(_('Quantity must be greater than zero'))
|
||||
|
||||
return quantity
|
||||
|
||||
def validate(self, data):
|
||||
"""Validate the serializer data."""
|
||||
data = super().validate(data)
|
||||
|
||||
build_item = data['build_item']
|
||||
quantity = data['quantity']
|
||||
|
||||
if quantity > build_item.quantity:
|
||||
raise ValidationError({
|
||||
'quantity': _('Consumed quantity exceeds allocated quantity')
|
||||
})
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class BuildConsumeLineItemSerializer(serializers.Serializer):
|
||||
"""Serializer for an individual BuildLine to be consumed against a BuildOrder."""
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass."""
|
||||
|
||||
fields = ['build_line']
|
||||
|
||||
build_line = serializers.PrimaryKeyRelatedField(
|
||||
queryset=BuildLine.objects.all(), many=False, allow_null=False, required=True
|
||||
)
|
||||
|
||||
|
||||
class BuildConsumeSerializer(serializers.Serializer):
|
||||
"""Serializer for consuming allocations against a BuildOrder.
|
||||
|
||||
- Consumes allocated stock items, increasing the 'consumed' field for each BuildLine.
|
||||
- Stock can be consumed by passing either a list of BuildItem objects, or a list of BuildLine objects.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass."""
|
||||
|
||||
fields = ['items', 'lines', 'notes']
|
||||
|
||||
items = BuildConsumeAllocationSerializer(many=True, required=False)
|
||||
|
||||
lines = BuildConsumeLineItemSerializer(many=True, required=False)
|
||||
|
||||
notes = serializers.CharField(
|
||||
label=_('Notes'),
|
||||
help_text=_('Optional notes for the stock consumption'),
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
)
|
||||
|
||||
def validate_items(self, items):
|
||||
"""Validate the BuildItem list passed to the serializer."""
|
||||
build_order = self.context['build']
|
||||
|
||||
seen = set()
|
||||
|
||||
for item in items:
|
||||
build_item = item['build_item']
|
||||
|
||||
# BuildItem must point to the correct build order
|
||||
if build_item.build != build_order:
|
||||
raise ValidationError(
|
||||
_('Build item must point to the correct build order')
|
||||
)
|
||||
|
||||
# Prevent duplicate item allocation
|
||||
if build_item.pk in seen:
|
||||
raise ValidationError(_('Duplicate build item allocation'))
|
||||
|
||||
seen.add(build_item.pk)
|
||||
|
||||
return items
|
||||
|
||||
def validate_lines(self, lines):
|
||||
"""Validate the BuildLine list passed to the serializer."""
|
||||
build_order = self.context['build']
|
||||
|
||||
seen = set()
|
||||
|
||||
for line in lines:
|
||||
build_line = line['build_line']
|
||||
|
||||
# BuildLine must point to the correct build order
|
||||
if build_line.build != build_order:
|
||||
raise ValidationError(
|
||||
_('Build line must point to the correct build order')
|
||||
)
|
||||
|
||||
# Prevent duplicate line allocation
|
||||
if build_line.pk in seen:
|
||||
raise ValidationError(_('Duplicate build line allocation'))
|
||||
|
||||
seen.add(build_line.pk)
|
||||
|
||||
return lines
|
||||
|
||||
def validate(self, data):
|
||||
"""Validate the serializer data."""
|
||||
items = data.get('items', [])
|
||||
lines = data.get('lines', [])
|
||||
|
||||
if len(items) == 0 and len(lines) == 0:
|
||||
raise ValidationError(_('At least one item or line must be provided'))
|
||||
|
||||
return data
|
||||
|
||||
def save(self):
|
||||
"""Perform the stock consumption step."""
|
||||
data = self.validated_data
|
||||
request = self.context.get('request')
|
||||
notes = data.get('notes', '')
|
||||
|
||||
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
|
||||
|
||||
build_item.complete_allocation(
|
||||
quantity=quantity,
|
||||
notes=notes,
|
||||
user=request.user if request else None,
|
||||
)
|
||||
|
||||
# Process the provided BuildLine objects
|
||||
for line in lines:
|
||||
build_line = line['build_line']
|
||||
|
||||
# In this case, perform full consumption of all allocated stock
|
||||
for item in build_line.allocations.all():
|
||||
# If the build item is tracked into an output, we do not consume now
|
||||
# Instead, it gets consumed when the output is completed
|
||||
if item.install_into:
|
||||
continue
|
||||
|
||||
item.complete_allocation(
|
||||
quantity=item.quantity,
|
||||
notes=notes,
|
||||
user=request.user if request else None,
|
||||
)
|
||||
|
||||
@@ -990,11 +990,12 @@ class BuildOverallocationTest(BuildAPITest):
|
||||
self.assertEqual(si.quantity, oq)
|
||||
|
||||
# Accept overallocated stock
|
||||
# TODO: (2025-07-16) Look into optimizing this API query to reduce DB hits
|
||||
self.post(
|
||||
self.url,
|
||||
{'accept_overallocated': 'accept'},
|
||||
expected_code=201,
|
||||
max_query_count=375,
|
||||
max_query_count=400,
|
||||
)
|
||||
|
||||
self.build.refresh_from_db()
|
||||
@@ -1009,11 +1010,12 @@ class BuildOverallocationTest(BuildAPITest):
|
||||
|
||||
def test_overallocated_can_trim(self):
|
||||
"""Test build order will trim/de-allocate overallocated stock when requested."""
|
||||
# TODO: (2025-07-16) Look into optimizing this API query to reduce DB hits
|
||||
self.post(
|
||||
self.url,
|
||||
{'accept_overallocated': 'trim'},
|
||||
expected_code=201,
|
||||
max_query_count=400,
|
||||
max_query_count=450,
|
||||
)
|
||||
|
||||
# Note: Large number of queries is due to pricing recalculation for each stock item
|
||||
@@ -1323,3 +1325,177 @@ class BuildLineTests(BuildAPITest):
|
||||
self.assertGreater(n_f, 0)
|
||||
|
||||
self.assertEqual(n_t + n_f, BuildLine.objects.count())
|
||||
|
||||
def test_filter_consumed(self):
|
||||
"""Filter for the 'consumed' status."""
|
||||
# Create a new build order
|
||||
assembly = Part.objects.create(
|
||||
name='Test Assembly',
|
||||
description='Test Assembly Description',
|
||||
assembly=True,
|
||||
trackable=True,
|
||||
)
|
||||
|
||||
for idx in range(3):
|
||||
component = Part.objects.create(
|
||||
name=f'Test Component {idx}',
|
||||
description=f'Test Component Description {idx}',
|
||||
trackable=True,
|
||||
component=True,
|
||||
)
|
||||
|
||||
# Create a BOM item for the assembly
|
||||
BomItem.objects.create(part=assembly, sub_part=component, quantity=10)
|
||||
|
||||
build = Build.objects.create(
|
||||
part=assembly, reference='BO-12348', quantity=10, title='Test Build'
|
||||
)
|
||||
|
||||
lines = list(build.build_lines.all())
|
||||
self.assertEqual(len(lines), 3)
|
||||
|
||||
for line in lines:
|
||||
self.assertEqual(line.quantity, 100)
|
||||
self.assertEqual(line.consumed, 0)
|
||||
|
||||
# Artificially "consume" some of the build lines
|
||||
lines[0].consumed = 1
|
||||
lines[0].save()
|
||||
|
||||
lines[1].consumed = 50
|
||||
lines[1].save()
|
||||
|
||||
lines[2].consumed = 100
|
||||
lines[2].save()
|
||||
|
||||
url = reverse('api-build-line-list')
|
||||
|
||||
response = self.get(url, {'build': build.pk, 'consumed': True})
|
||||
|
||||
self.assertEqual(len(response.data), 1)
|
||||
self.assertEqual(response.data[0]['pk'], lines[2].pk)
|
||||
self.assertEqual(response.data[0]['consumed'], 100)
|
||||
self.assertEqual(response.data[0]['quantity'], 100)
|
||||
|
||||
response = self.get(url, {'build': build.pk, 'consumed': False})
|
||||
|
||||
self.assertEqual(len(response.data), 2)
|
||||
self.assertEqual(response.data[0]['pk'], lines[0].pk)
|
||||
self.assertEqual(response.data[0]['consumed'], 1)
|
||||
self.assertEqual(response.data[0]['quantity'], 100)
|
||||
self.assertEqual(response.data[1]['pk'], lines[1].pk)
|
||||
self.assertEqual(response.data[1]['consumed'], 50)
|
||||
self.assertEqual(response.data[1]['quantity'], 100)
|
||||
|
||||
# Check that the 'available' filter works correctly also when lines are partially consumed
|
||||
for line in lines:
|
||||
StockItem.objects.create(part=line.bom_item.sub_part, quantity=60)
|
||||
|
||||
# Note: The max_query_time is bumped up here, as postgresql backend has some strange issues (only during testing)
|
||||
response = self.get(
|
||||
url, {'build': build.pk, 'available': True}, max_query_time=15
|
||||
)
|
||||
|
||||
# We expect 2 lines to have "available" stock
|
||||
self.assertEqual(len(response.data), 2)
|
||||
|
||||
# Note: The max_query_time is bumped up here, as postgresql backend has some strange issues (only during testing)
|
||||
response = self.get(
|
||||
url, {'build': build.pk, 'available': False}, max_query_time=15
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 1)
|
||||
self.assertEqual(response.data[0]['pk'], lines[0].pk)
|
||||
|
||||
|
||||
class BuildConsumeTest(BuildAPITest):
|
||||
"""Test consuming allocated stock."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
super().setUp()
|
||||
|
||||
self.assembly = Part.objects.create(
|
||||
name='Test Assembly', description='Test Assembly Description', assembly=True
|
||||
)
|
||||
|
||||
self.components = [
|
||||
Part.objects.create(
|
||||
name=f'Test Component {i}',
|
||||
description=f'Test Component Description {i}',
|
||||
component=True,
|
||||
)
|
||||
for i in range(3)
|
||||
]
|
||||
|
||||
self.stock_items = [
|
||||
StockItem.objects.create(part=component, quantity=1000)
|
||||
for component in self.components
|
||||
]
|
||||
|
||||
self.bom_items = [
|
||||
BomItem.objects.create(part=self.assembly, sub_part=component, quantity=10)
|
||||
for component in self.components
|
||||
]
|
||||
|
||||
self.build = Build.objects.create(
|
||||
part=self.assembly, reference='BO-12349', quantity=10, title='Test Build'
|
||||
)
|
||||
|
||||
def allocate_stock(self):
|
||||
"""Allocate stock items to the build."""
|
||||
data = {
|
||||
'items': [
|
||||
{'build_line': line.pk, 'stock_item': si.pk, 'quantity': 100}
|
||||
for line, si in zip(self.build.build_lines.all(), self.stock_items)
|
||||
]
|
||||
}
|
||||
|
||||
self.post(
|
||||
reverse('api-build-allocate', kwargs={'pk': self.build.pk}),
|
||||
data,
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
def test_consume_lines(self):
|
||||
"""Test consuming against build lines."""
|
||||
self.allocate_stock()
|
||||
|
||||
self.assertEqual(self.build.allocated_stock.count(), 3)
|
||||
self.assertEqual(self.build.consumed_stock.count(), 0)
|
||||
url = reverse('api-build-consume', kwargs={'pk': self.build.pk})
|
||||
|
||||
data = {
|
||||
'lines': [{'build_line': line.pk} for line in self.build.build_lines.all()]
|
||||
}
|
||||
|
||||
self.post(url, data, expected_code=201)
|
||||
|
||||
self.assertEqual(self.build.allocated_stock.count(), 0)
|
||||
self.assertEqual(self.build.consumed_stock.count(), 3)
|
||||
|
||||
for line in self.build.build_lines.all():
|
||||
self.assertEqual(line.consumed, 100)
|
||||
|
||||
def test_consume_items(self):
|
||||
"""Test consuming against build items."""
|
||||
self.allocate_stock()
|
||||
|
||||
self.assertEqual(self.build.allocated_stock.count(), 3)
|
||||
self.assertEqual(self.build.consumed_stock.count(), 0)
|
||||
url = reverse('api-build-consume', kwargs={'pk': self.build.pk})
|
||||
|
||||
data = {
|
||||
'items': [
|
||||
{'build_item': item.pk, 'quantity': item.quantity}
|
||||
for item in self.build.allocated_stock.all()
|
||||
]
|
||||
}
|
||||
|
||||
self.post(url, data, expected_code=201)
|
||||
|
||||
self.assertEqual(self.build.allocated_stock.count(), 0)
|
||||
self.assertEqual(self.build.consumed_stock.count(), 3)
|
||||
|
||||
for line in self.build.build_lines.all():
|
||||
self.assertEqual(line.consumed, 100)
|
||||
|
||||
@@ -1400,9 +1400,10 @@ class Part(
|
||||
|
||||
# Match BOM item to build
|
||||
for bom_item in bom_items:
|
||||
build_quantity = build.quantity * bom_item.quantity
|
||||
build_line = build.build_lines.filter(bom_item=bom_item).first()
|
||||
|
||||
quantity += build_quantity
|
||||
line_quantity = max(0, build_line.quantity - build_line.consumed)
|
||||
quantity += line_quantity
|
||||
|
||||
return quantity
|
||||
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Badge, Skeleton } from '@mantine/core';
|
||||
import { Badge, type MantineColor, Skeleton } from '@mantine/core';
|
||||
|
||||
import { isTrue } from '../functions/Conversion';
|
||||
|
||||
export function PassFailButton({
|
||||
value,
|
||||
passText,
|
||||
failText
|
||||
failText,
|
||||
passColor,
|
||||
failColor
|
||||
}: Readonly<{
|
||||
value: any;
|
||||
passText?: string;
|
||||
failText?: string;
|
||||
passColor?: MantineColor;
|
||||
failColor?: MantineColor;
|
||||
}>) {
|
||||
const v = isTrue(value);
|
||||
const pass = passText ?? t`Pass`;
|
||||
const fail = failText ?? t`Fail`;
|
||||
|
||||
const pColor = passColor ?? 'green';
|
||||
const fColor = failColor ?? 'red';
|
||||
|
||||
return (
|
||||
<Badge
|
||||
color={v ? 'green' : 'red'}
|
||||
color={v ? pColor : fColor}
|
||||
variant='filled'
|
||||
radius='lg'
|
||||
size='sm'
|
||||
@@ -30,7 +37,14 @@ export function PassFailButton({
|
||||
}
|
||||
|
||||
export function YesNoButton({ value }: Readonly<{ value: any }>) {
|
||||
return <PassFailButton value={value} passText={t`Yes`} failText={t`No`} />;
|
||||
return (
|
||||
<PassFailButton
|
||||
value={value}
|
||||
passText={t`Yes`}
|
||||
failText={t`No`}
|
||||
failColor={'orange.6'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function YesNoUndefinedButton({ value }: Readonly<{ value?: boolean }>) {
|
||||
|
||||
@@ -96,6 +96,7 @@ export enum ApiEndpoints {
|
||||
build_output_delete = 'build/:id/delete-outputs/',
|
||||
build_order_auto_allocate = 'build/:id/auto-allocate/',
|
||||
build_order_allocate = 'build/:id/allocate/',
|
||||
build_order_consume = 'build/:id/consume/',
|
||||
build_order_deallocate = 'build/:id/unallocate/',
|
||||
|
||||
build_line_list = 'build/line/',
|
||||
|
||||
@@ -60,6 +60,10 @@ export function RenderPartCategory(
|
||||
): ReactNode {
|
||||
const { instance } = props;
|
||||
|
||||
if (!instance) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const suffix: ReactNode = (
|
||||
<Group gap='xs'>
|
||||
<TableHoverCard
|
||||
|
||||
@@ -22,6 +22,10 @@ export function RenderStockLocation(
|
||||
): ReactNode {
|
||||
const { instance } = props;
|
||||
|
||||
if (!instance) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const suffix: ReactNode = (
|
||||
<Group gap='xs'>
|
||||
<TableHoverCard
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Alert, Divider, List, Stack, Table } from '@mantine/core';
|
||||
import { Alert, Divider, Group, List, Stack, Table, Text } from '@mantine/core';
|
||||
import {
|
||||
IconCalendar,
|
||||
IconCircleCheck,
|
||||
IconInfoCircle,
|
||||
IconLink,
|
||||
IconList,
|
||||
@@ -25,7 +26,10 @@ import {
|
||||
type TableFieldRowProps
|
||||
} from '../components/forms/fields/TableField';
|
||||
import { StatusRenderer } from '../components/render/StatusRenderer';
|
||||
import { RenderStockItem } from '../components/render/Stock';
|
||||
import {
|
||||
RenderStockItem,
|
||||
RenderStockLocation
|
||||
} from '../components/render/Stock';
|
||||
import { useCreateApiFormModal } from '../hooks/UseForm';
|
||||
import {
|
||||
useBatchCodeGenerator,
|
||||
@@ -542,7 +546,7 @@ function BuildAllocateLineRow({
|
||||
<Table.Td>
|
||||
<ProgressBar
|
||||
value={record.allocatedQuantity}
|
||||
maximum={record.requiredQuantity}
|
||||
maximum={record.requiredQuantity - record.consumed}
|
||||
progressLabel
|
||||
/>
|
||||
</Table.Td>
|
||||
@@ -670,15 +674,220 @@ export function useAllocateStockToBuildForm({
|
||||
successMessage: t`Stock items allocated`,
|
||||
onFormSuccess: onFormSuccess,
|
||||
initialData: {
|
||||
items: lineItems.map((item) => {
|
||||
return {
|
||||
build_line: item.pk,
|
||||
stock_item: undefined,
|
||||
quantity: Math.max(0, item.requiredQuantity - item.allocatedQuantity),
|
||||
output: outputId
|
||||
};
|
||||
})
|
||||
items: lineItems
|
||||
.filter((item) => {
|
||||
return item.requiredQuantity > item.allocatedQuantity + item.consumed;
|
||||
})
|
||||
.map((item) => {
|
||||
return {
|
||||
build_line: item.pk,
|
||||
stock_item: undefined,
|
||||
quantity: Math.max(
|
||||
0,
|
||||
item.requiredQuantity - item.allocatedQuantity - item.consumed
|
||||
),
|
||||
output: outputId
|
||||
};
|
||||
})
|
||||
},
|
||||
size: '80%'
|
||||
});
|
||||
}
|
||||
|
||||
function BuildConsumeItemRow({
|
||||
props,
|
||||
record
|
||||
}: {
|
||||
props: TableFieldRowProps;
|
||||
record: any;
|
||||
}) {
|
||||
return (
|
||||
<Table.Tr key={`table-row-${record.pk}`}>
|
||||
<Table.Td>
|
||||
<PartColumn part={record.part_detail} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<RenderStockItem instance={record.stock_item_detail} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{record.location_detail && (
|
||||
<RenderStockLocation instance={record.location_detail} />
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{record.quantity}</Table.Td>
|
||||
<Table.Td>
|
||||
<StandaloneField
|
||||
fieldName='quantity'
|
||||
fieldDefinition={{
|
||||
field_type: 'number',
|
||||
required: true,
|
||||
value: props.item.quantity,
|
||||
onValueChange: (value: any) => {
|
||||
props.changeFn(props.idx, 'quantity', value);
|
||||
}
|
||||
}}
|
||||
error={props.rowErrors?.quantity?.message}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic form for consuming stock against multiple BuildItem records
|
||||
*/
|
||||
export function useConsumeBuildItemsForm({
|
||||
buildId,
|
||||
allocatedItems,
|
||||
onFormSuccess
|
||||
}: {
|
||||
buildId: number;
|
||||
allocatedItems: any[];
|
||||
onFormSuccess: (response: any) => void;
|
||||
}) {
|
||||
const consumeFields: ApiFormFieldSet = useMemo(() => {
|
||||
return {
|
||||
items: {
|
||||
field_type: 'table',
|
||||
value: [],
|
||||
headers: [
|
||||
{ title: t`Part` },
|
||||
{ title: t`Stock Item` },
|
||||
{ title: t`Location` },
|
||||
{ title: t`Allocated` },
|
||||
{ title: t`Quantity` }
|
||||
],
|
||||
modelRenderer: (row: TableFieldRowProps) => {
|
||||
const record = allocatedItems.find(
|
||||
(item) => item.pk == row.item.build_item
|
||||
);
|
||||
|
||||
return (
|
||||
<BuildConsumeItemRow key={row.idx} props={row} record={record} />
|
||||
);
|
||||
}
|
||||
},
|
||||
notes: {}
|
||||
};
|
||||
}, [allocatedItems]);
|
||||
|
||||
return useCreateApiFormModal({
|
||||
url: ApiEndpoints.build_order_consume,
|
||||
pk: buildId,
|
||||
title: t`Consume Stock`,
|
||||
successMessage: t`Stock items consumed`,
|
||||
onFormSuccess: onFormSuccess,
|
||||
size: '80%',
|
||||
fields: consumeFields,
|
||||
initialData: {
|
||||
items: allocatedItems.map((item) => {
|
||||
return {
|
||||
build_item: item.pk,
|
||||
quantity: item.quantity
|
||||
};
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function BuildConsumeLineRow({
|
||||
props,
|
||||
record
|
||||
}: {
|
||||
props: TableFieldRowProps;
|
||||
record: any;
|
||||
}) {
|
||||
const allocated: number = record.allocatedQuantity ?? record.allocated;
|
||||
const required: number = record.requiredQuantity ?? record.required;
|
||||
const remaining: number = Math.max(0, required - record.consumed);
|
||||
|
||||
return (
|
||||
<Table.Tr key={`table-row-${record.pk}`}>
|
||||
<Table.Td>
|
||||
<PartColumn part={record.part_detail} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{remaining <= 0 ? (
|
||||
<Group gap='xs'>
|
||||
<IconCircleCheck size={16} color='green' />
|
||||
<Text size='sm' style={{ fontStyle: 'italic' }}>
|
||||
{t`Fully consumed`}
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<ProgressBar value={allocated} maximum={remaining} progressLabel />
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ProgressBar
|
||||
value={record.consumed}
|
||||
maximum={record.quantity}
|
||||
progressLabel
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic form for consuming stock against multiple BuildLine records
|
||||
*/
|
||||
export function useConsumeBuildLinesForm({
|
||||
buildId,
|
||||
buildLines,
|
||||
onFormSuccess
|
||||
}: {
|
||||
buildId: number;
|
||||
buildLines: any[];
|
||||
onFormSuccess: (response: any) => void;
|
||||
}) {
|
||||
const filteredLines = useMemo(() => {
|
||||
return buildLines.filter((line) => !line.part_detail?.trackable);
|
||||
}, [buildLines]);
|
||||
|
||||
const consumeFields: ApiFormFieldSet = useMemo(() => {
|
||||
return {
|
||||
lines: {
|
||||
field_type: 'table',
|
||||
value: [],
|
||||
headers: [
|
||||
{ title: t`Part` },
|
||||
{ title: t`Allocated` },
|
||||
{ title: t`Consumed` }
|
||||
],
|
||||
modelRenderer: (row: TableFieldRowProps) => {
|
||||
const record = filteredLines.find(
|
||||
(item) => item.pk == row.item.build_line
|
||||
);
|
||||
|
||||
return (
|
||||
<BuildConsumeLineRow key={row.idx} props={row} record={record} />
|
||||
);
|
||||
}
|
||||
},
|
||||
notes: {}
|
||||
};
|
||||
}, [filteredLines]);
|
||||
|
||||
return useCreateApiFormModal({
|
||||
url: ApiEndpoints.build_order_consume,
|
||||
pk: buildId,
|
||||
title: t`Consume Stock`,
|
||||
successMessage: t`Stock items consumed`,
|
||||
onFormSuccess: onFormSuccess,
|
||||
fields: consumeFields,
|
||||
initialData: {
|
||||
lines: filteredLines.map((item) => {
|
||||
return {
|
||||
build_line: item.pk
|
||||
};
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
import { Thumbnail } from '../components/images/Thumbnail';
|
||||
import { StylishText } from '../components/items/StylishText';
|
||||
import { StatusRenderer } from '../components/render/StatusRenderer';
|
||||
import { RenderStockLocation } from '../components/render/Stock';
|
||||
import { InvenTreeIcon } from '../functions/icons';
|
||||
import {
|
||||
useApiFormModal,
|
||||
@@ -576,7 +577,7 @@ function StockOperationsRow({
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{record.location ? record.location_detail?.pathstring : '-'}
|
||||
<RenderStockLocation instance={record.location_detail} />
|
||||
</Table.Td>
|
||||
<Table.Td>{record.batch ? record.batch : '-'}</Table.Td>
|
||||
<Table.Td>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Common rendering functions for table column data.
|
||||
*/
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Anchor, Group, Skeleton, Text, Tooltip } from '@mantine/core';
|
||||
import { Anchor, Center, Group, Skeleton, Text, Tooltip } from '@mantine/core';
|
||||
import {
|
||||
IconBell,
|
||||
IconExclamationCircle,
|
||||
@@ -210,7 +210,9 @@ export function BooleanColumn(props: TableColumn): TableColumn {
|
||||
sortable: true,
|
||||
switchable: true,
|
||||
render: (record: any) => (
|
||||
<YesNoButton value={resolveItem(record, props.accessor ?? '')} />
|
||||
<Center>
|
||||
<YesNoButton value={resolveItem(record, props.accessor ?? '')} />
|
||||
</Center>
|
||||
),
|
||||
...props
|
||||
};
|
||||
|
||||
@@ -10,8 +10,11 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { ActionButton } from '@lib/index';
|
||||
import type { TableFilter } from '@lib/types/Filters';
|
||||
import type { TableColumn } from '@lib/types/Tables';
|
||||
import { IconCircleDashedCheck } from '@tabler/icons-react';
|
||||
import { useConsumeBuildItemsForm } from '../../forms/BuildForms';
|
||||
import type { StockOperationProps } from '../../forms/StockForms';
|
||||
import {
|
||||
useDeleteApiFormModal,
|
||||
@@ -160,10 +163,10 @@ export default function BuildAllocatedStockTable({
|
||||
];
|
||||
}, []);
|
||||
|
||||
const [selectedItem, setSelectedItem] = useState<number>(0);
|
||||
const [selectedItemId, setSelectedItemId] = useState<number>(0);
|
||||
|
||||
const editItem = useEditApiFormModal({
|
||||
pk: selectedItem,
|
||||
pk: selectedItemId,
|
||||
url: ApiEndpoints.build_item_list,
|
||||
title: t`Edit Stock Allocation`,
|
||||
fields: {
|
||||
@@ -176,12 +179,23 @@ export default function BuildAllocatedStockTable({
|
||||
});
|
||||
|
||||
const deleteItem = useDeleteApiFormModal({
|
||||
pk: selectedItem,
|
||||
pk: selectedItemId,
|
||||
url: ApiEndpoints.build_item_list,
|
||||
title: t`Delete Stock Allocation`,
|
||||
table: table
|
||||
});
|
||||
|
||||
const [selectedItems, setSelectedItems] = useState<any[]>([]);
|
||||
|
||||
const consumeStock = useConsumeBuildItemsForm({
|
||||
buildId: buildId ?? 0,
|
||||
allocatedItems: selectedItems,
|
||||
onFormSuccess: () => {
|
||||
table.clearSelectedRecords();
|
||||
table.refreshTable();
|
||||
}
|
||||
});
|
||||
|
||||
const stockOperationProps: StockOperationProps = useMemo(() => {
|
||||
// Extract stock items from the selected records
|
||||
// Note that the table is actually a list of BuildItem instances,
|
||||
@@ -216,17 +230,28 @@ export default function BuildAllocatedStockTable({
|
||||
const rowActions = useCallback(
|
||||
(record: any): RowAction[] => {
|
||||
return [
|
||||
{
|
||||
color: 'green',
|
||||
icon: <IconCircleDashedCheck />,
|
||||
title: t`Consume`,
|
||||
tooltip: t`Consume Stock`,
|
||||
hidden: !user.hasChangeRole(UserRoles.build),
|
||||
onClick: () => {
|
||||
setSelectedItems([record]);
|
||||
consumeStock.open();
|
||||
}
|
||||
},
|
||||
RowEditAction({
|
||||
hidden: !user.hasChangeRole(UserRoles.build),
|
||||
onClick: () => {
|
||||
setSelectedItem(record.pk);
|
||||
setSelectedItemId(record.pk);
|
||||
editItem.open();
|
||||
}
|
||||
}),
|
||||
RowDeleteAction({
|
||||
hidden: !user.hasDeleteRole(UserRoles.build),
|
||||
onClick: () => {
|
||||
setSelectedItem(record.pk);
|
||||
setSelectedItemId(record.pk);
|
||||
deleteItem.open();
|
||||
}
|
||||
})
|
||||
@@ -236,13 +261,28 @@ export default function BuildAllocatedStockTable({
|
||||
);
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
return [stockAdjustActions.dropdown];
|
||||
}, [stockAdjustActions.dropdown]);
|
||||
return [
|
||||
stockAdjustActions.dropdown,
|
||||
<ActionButton
|
||||
key='consume-stock'
|
||||
icon={<IconCircleDashedCheck />}
|
||||
tooltip={t`Consume Stock`}
|
||||
hidden={!user.hasChangeRole(UserRoles.build)}
|
||||
disabled={table.selectedRecords.length == 0}
|
||||
color='green'
|
||||
onClick={() => {
|
||||
setSelectedItems(table.selectedRecords);
|
||||
consumeStock.open();
|
||||
}}
|
||||
/>
|
||||
];
|
||||
}, [user, table.selectedRecords, stockAdjustActions.dropdown]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{editItem.modal}
|
||||
{deleteItem.modal}
|
||||
{consumeStock.modal}
|
||||
{stockAdjustActions.modals.map((modal) => modal.modal)}
|
||||
<InvenTreeTable
|
||||
tableState={table}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Alert, Group, Paper, Stack, Text } from '@mantine/core';
|
||||
import { Alert, Group, Paper, Text } from '@mantine/core';
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconCircleCheck,
|
||||
IconCircleDashedCheck,
|
||||
IconCircleMinus,
|
||||
IconShoppingCart,
|
||||
IconTool,
|
||||
IconWand
|
||||
} from '@tabler/icons-react';
|
||||
import { DataTable, type DataTableRowExpansionProps } from 'mantine-datatable';
|
||||
import type { DataTableRowExpansionProps } from 'mantine-datatable';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { ActionButton } from '@lib/components/ActionButton';
|
||||
import { ProgressBar } from '@lib/components/ProgressBar';
|
||||
import {
|
||||
type RowAction,
|
||||
RowActions,
|
||||
RowDeleteAction,
|
||||
RowEditAction,
|
||||
RowViewAction
|
||||
@@ -26,11 +26,12 @@ import { UserRoles } from '@lib/enums/Roles';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { formatDecimal } from '@lib/functions/Formatting';
|
||||
import type { TableFilter } from '@lib/types/Filters';
|
||||
import type { TableColumn } from '@lib/types/Tables';
|
||||
import type { RowAction, TableColumn } from '@lib/types/Tables';
|
||||
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
|
||||
import {
|
||||
useAllocateStockToBuildForm,
|
||||
useBuildOrderFields
|
||||
useBuildOrderFields,
|
||||
useConsumeBuildLinesForm
|
||||
} from '../../forms/BuildForms';
|
||||
import {
|
||||
useCreateApiFormModal,
|
||||
@@ -70,6 +71,7 @@ export function BuildLineSubTable({
|
||||
}>) {
|
||||
const user = useUserState();
|
||||
const navigate = useNavigate();
|
||||
const table = useTable('buildline-subtable');
|
||||
|
||||
const tableColumns: any[] = useMemo(() => {
|
||||
return [
|
||||
@@ -96,59 +98,52 @@ export function BuildLineSubTable({
|
||||
},
|
||||
LocationColumn({
|
||||
accessor: 'location_detail'
|
||||
}),
|
||||
{
|
||||
accessor: '---actions---',
|
||||
title: ' ',
|
||||
width: 50,
|
||||
render: (record: any) => {
|
||||
return (
|
||||
<RowActions
|
||||
title={t`Actions`}
|
||||
index={record.pk}
|
||||
actions={[
|
||||
RowViewAction({
|
||||
title: t`View Stock Item`,
|
||||
modelType: ModelType.stockitem,
|
||||
modelId: record.stock_item,
|
||||
navigate: navigate
|
||||
}),
|
||||
RowEditAction({
|
||||
hidden:
|
||||
!onEditAllocation || !user.hasChangeRole(UserRoles.build),
|
||||
onClick: () => {
|
||||
onEditAllocation?.(record.pk);
|
||||
}
|
||||
}),
|
||||
RowDeleteAction({
|
||||
hidden:
|
||||
!onDeleteAllocation || !user.hasDeleteRole(UserRoles.build),
|
||||
onClick: () => {
|
||||
onDeleteAllocation?.(record.pk);
|
||||
}
|
||||
})
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
];
|
||||
}, [user, onEditAllocation, onDeleteAllocation]);
|
||||
}, []);
|
||||
|
||||
const rowActions = useCallback(
|
||||
(record: any): RowAction[] => {
|
||||
return [
|
||||
RowViewAction({
|
||||
title: t`View Stock Item`,
|
||||
modelType: ModelType.stockitem,
|
||||
modelId: record.stock_item,
|
||||
navigate: navigate
|
||||
}),
|
||||
RowEditAction({
|
||||
hidden: !onEditAllocation || !user.hasChangeRole(UserRoles.build),
|
||||
onClick: () => {
|
||||
onEditAllocation?.(record.pk);
|
||||
}
|
||||
}),
|
||||
RowDeleteAction({
|
||||
hidden: !onDeleteAllocation || !user.hasDeleteRole(UserRoles.build),
|
||||
onClick: () => {
|
||||
onDeleteAllocation?.(record.pk);
|
||||
}
|
||||
})
|
||||
];
|
||||
},
|
||||
[user, onEditAllocation, onDeleteAllocation]
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper p='md'>
|
||||
<Stack gap='xs'>
|
||||
<DataTable
|
||||
minHeight={50}
|
||||
withTableBorder
|
||||
withColumnBorders
|
||||
striped
|
||||
pinLastColumn
|
||||
idAccessor='pk'
|
||||
columns={tableColumns}
|
||||
records={lineItem.filteredAllocations ?? lineItem.allocations}
|
||||
/>
|
||||
</Stack>
|
||||
<Paper p='xs'>
|
||||
<InvenTreeTable
|
||||
tableState={table}
|
||||
columns={tableColumns}
|
||||
tableData={lineItem.filteredAllocations ?? lineItem.allocations}
|
||||
props={{
|
||||
minHeight: 200,
|
||||
enableSearch: false,
|
||||
enableRefresh: false,
|
||||
enableColumnSwitching: false,
|
||||
enableFilters: false,
|
||||
rowActions: rowActions,
|
||||
noRecordsText: ''
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -186,7 +181,12 @@ export default function BuildLineTable({
|
||||
{
|
||||
name: 'allocated',
|
||||
label: t`Allocated`,
|
||||
description: t`Show allocated lines`
|
||||
description: t`Show fully allocated lines`
|
||||
},
|
||||
{
|
||||
name: 'consumed',
|
||||
label: t`Consumed`,
|
||||
description: t`Show fully consumed lines`
|
||||
},
|
||||
{
|
||||
name: 'available',
|
||||
@@ -471,13 +471,56 @@ export default function BuildLineTable({
|
||||
switchable: false,
|
||||
sortable: true,
|
||||
hidden: !isActive,
|
||||
render: (record: any) => {
|
||||
if (record?.bom_item_detail?.consumable) {
|
||||
return (
|
||||
<Text
|
||||
size='sm'
|
||||
style={{ fontStyle: 'italic' }}
|
||||
>{t`Consumable item`}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
let required = Math.max(0, record.quantity - record.consumed);
|
||||
|
||||
if (output?.pk) {
|
||||
// If an output is specified, we show the allocated quantity for that output
|
||||
required = record.bom_item_detail?.quantity;
|
||||
}
|
||||
|
||||
if (required <= 0) {
|
||||
return (
|
||||
<Group gap='xs' wrap='nowrap'>
|
||||
<IconCircleCheck size={16} color='green' />
|
||||
<Text size='sm' style={{ fontStyle: 'italic' }}>
|
||||
{record.consumed >= record.quantity
|
||||
? t`Fully consumed`
|
||||
: t`Fully allocated`}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProgressBar
|
||||
progressLabel={true}
|
||||
value={record.allocatedQuantity}
|
||||
maximum={required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'consumed',
|
||||
sortable: true,
|
||||
hidden: !!output?.pk,
|
||||
render: (record: any) => {
|
||||
return record?.bom_item_detail?.consumable ? (
|
||||
<Text style={{ fontStyle: 'italic' }}>{t`Consumable item`}</Text>
|
||||
) : (
|
||||
<ProgressBar
|
||||
progressLabel={true}
|
||||
value={record.allocatedQuantity}
|
||||
value={record.consumed}
|
||||
maximum={record.requiredQuantity}
|
||||
/>
|
||||
);
|
||||
@@ -544,6 +587,7 @@ export default function BuildLineTable({
|
||||
buildId: build.pk,
|
||||
lineItems: selectedRows,
|
||||
onFormSuccess: () => {
|
||||
table.clearSelectedRecords();
|
||||
table.refreshTable();
|
||||
}
|
||||
});
|
||||
@@ -574,7 +618,10 @@ export default function BuildLineTable({
|
||||
</Alert>
|
||||
),
|
||||
successMessage: t`Stock has been deallocated`,
|
||||
table: table
|
||||
onFormSuccess: () => {
|
||||
table.clearSelectedRecords();
|
||||
table.refreshTable();
|
||||
}
|
||||
});
|
||||
|
||||
const [selectedAllocation, setSelectedAllocation] = useState<number>(0);
|
||||
@@ -605,6 +652,15 @@ export default function BuildLineTable({
|
||||
parts: partsToOrder
|
||||
});
|
||||
|
||||
const consumeLines = useConsumeBuildLinesForm({
|
||||
buildId: build.pk,
|
||||
buildLines: selectedRows,
|
||||
onFormSuccess: () => {
|
||||
table.clearSelectedRecords();
|
||||
table.refreshTable();
|
||||
}
|
||||
});
|
||||
|
||||
const rowActions = useCallback(
|
||||
(record: any): RowAction[] => {
|
||||
const part = record.part_detail ?? {};
|
||||
@@ -613,11 +669,24 @@ export default function BuildLineTable({
|
||||
|
||||
const hasOutput = !!output?.pk;
|
||||
|
||||
const required = Math.max(
|
||||
0,
|
||||
record.quantity - record.consumed - record.allocated
|
||||
);
|
||||
|
||||
// Can consume
|
||||
const canConsume =
|
||||
in_production &&
|
||||
!consumable &&
|
||||
record.allocated > 0 &&
|
||||
user.hasChangeRole(UserRoles.build);
|
||||
|
||||
// Can allocate
|
||||
const canAllocate =
|
||||
in_production &&
|
||||
!consumable &&
|
||||
user.hasChangeRole(UserRoles.build) &&
|
||||
required > 0 &&
|
||||
record.trackable == hasOutput;
|
||||
|
||||
// Can de-allocate
|
||||
@@ -647,6 +716,16 @@ export default function BuildLineTable({
|
||||
allocateStock.open();
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: <IconCircleDashedCheck />,
|
||||
title: t`Consume Stock`,
|
||||
color: 'green',
|
||||
hidden: !canConsume || hasOutput,
|
||||
onClick: () => {
|
||||
setSelectedRows([record]);
|
||||
consumeLines.open();
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: <IconCircleMinus />,
|
||||
title: t`Deallocate Stock`,
|
||||
@@ -758,6 +837,18 @@ export default function BuildLineTable({
|
||||
setSelectedLine(null);
|
||||
deallocateStock.open();
|
||||
}}
|
||||
/>,
|
||||
<ActionButton
|
||||
key='consume-stock'
|
||||
icon={<IconCircleDashedCheck />}
|
||||
tooltip={t`Consume Stock`}
|
||||
hidden={!visible || hasOutput}
|
||||
disabled={!table.hasSelectedRecords}
|
||||
color='green'
|
||||
onClick={() => {
|
||||
setSelectedRows(table.selectedRecords);
|
||||
consumeLines.open();
|
||||
}}
|
||||
/>
|
||||
];
|
||||
}, [
|
||||
@@ -843,6 +934,7 @@ export default function BuildLineTable({
|
||||
{deallocateStock.modal}
|
||||
{editAllocation.modal}
|
||||
{deleteAllocation.modal}
|
||||
{consumeLines.modal}
|
||||
{orderPartsWizard.wizard}
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.build_line_list)}
|
||||
@@ -852,6 +944,7 @@ export default function BuildLineTable({
|
||||
params: {
|
||||
...params,
|
||||
build: build.pk,
|
||||
assembly_detail: false,
|
||||
part_detail: true
|
||||
},
|
||||
tableActions: tableActions,
|
||||
|
||||
@@ -95,20 +95,24 @@ function OutputAllocationDrawer({
|
||||
position='bottom'
|
||||
size='lg'
|
||||
title={
|
||||
<Group p='md' wrap='nowrap' justify='space-apart'>
|
||||
<Group p='xs' wrap='nowrap' justify='space-apart'>
|
||||
<StylishText size='lg'>{t`Build Output Stock Allocation`}</StylishText>
|
||||
<Space h='lg' />
|
||||
<PartColumn part={build.part_detail} />
|
||||
{output?.serial && (
|
||||
<Text size='sm'>
|
||||
{t`Serial Number`}: {output.serial}
|
||||
</Text>
|
||||
)}
|
||||
{output?.batch && (
|
||||
<Text size='sm'>
|
||||
{t`Batch Code`}: {output.batch}
|
||||
</Text>
|
||||
)}
|
||||
<Paper withBorder p='sm'>
|
||||
<Group gap='xs'>
|
||||
<PartColumn part={build.part_detail} />
|
||||
{output?.serial && (
|
||||
<Text size='sm'>
|
||||
{t`Serial Number`}: {output.serial}
|
||||
</Text>
|
||||
)}
|
||||
{output?.batch && (
|
||||
<Text size='sm'>
|
||||
{t`Batch Code`}: {output.batch}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
<Space h='lg' />
|
||||
</Group>
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { UserRoles } from '@lib/enums/Roles';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import type { TableFilter } from '@lib/types/Filters';
|
||||
import type { TableColumn } from '@lib/types/Tables';
|
||||
import { IconCircleCheck } from '@tabler/icons-react';
|
||||
import { useTable } from '../../hooks/UseTable';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import {
|
||||
@@ -92,13 +93,30 @@ export default function PartBuildAllocationsTable({
|
||||
sortable: true,
|
||||
switchable: false,
|
||||
title: t`Required Stock`,
|
||||
render: (record: any) => (
|
||||
<ProgressBar
|
||||
progressLabel
|
||||
value={record.allocated}
|
||||
maximum={record.quantity}
|
||||
/>
|
||||
)
|
||||
render: (record: any) => {
|
||||
const required = Math.max(0, record.quantity - record.consumed);
|
||||
|
||||
if (required <= 0) {
|
||||
return (
|
||||
<Group gap='xs' wrap='nowrap'>
|
||||
<IconCircleCheck size={14} color='green' />
|
||||
<Text size='sm' style={{ fontStyle: 'italic' }}>
|
||||
{record.consumed >= record.quantity
|
||||
? t`Fully consumed`
|
||||
: t`Fully allocated`}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProgressBar
|
||||
progressLabel
|
||||
value={record.allocated}
|
||||
maximum={required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
}, [table.isRowExpanded]);
|
||||
@@ -142,11 +160,13 @@ export default function PartBuildAllocationsTable({
|
||||
tableState={table}
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
minHeight: 200,
|
||||
minHeight: 300,
|
||||
params: {
|
||||
part: partId,
|
||||
consumable: false,
|
||||
part_detail: true,
|
||||
part_detail: false,
|
||||
bom_item_detail: false,
|
||||
project_code_detail: true,
|
||||
assembly_detail: true,
|
||||
build_detail: true,
|
||||
order_outstanding: true
|
||||
|
||||
@@ -350,6 +350,59 @@ test('Build Order - Allocation', async ({ browser }) => {
|
||||
.waitFor();
|
||||
});
|
||||
|
||||
// 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'
|
||||
});
|
||||
|
||||
// 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();
|
||||
|
||||
// Open the "Allocate Stock" dialog
|
||||
await page.getByRole('checkbox', { name: 'Select all records' }).click();
|
||||
await page
|
||||
.getByRole('button', { name: 'action-button-allocate-stock' })
|
||||
.click();
|
||||
await page
|
||||
.getByLabel('Allocate Stock')
|
||||
.getByText('5 / 35', { exact: true })
|
||||
.waitFor();
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
// Open the "Consume Stock" dialog
|
||||
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' })
|
||||
.fill('some notes here...');
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
// Try with a different build order
|
||||
await navigate(page, 'manufacturing/build-order/26/line-items');
|
||||
await page.getByRole('checkbox', { name: 'Select all records' }).click();
|
||||
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();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
});
|
||||
|
||||
test('Build Order - Tracked Outputs', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, {
|
||||
url: 'manufacturing/build-order/10/incomplete-outputs'
|
||||
|
||||
@@ -249,7 +249,13 @@ test('Parts - Requirements', async ({ browser }) => {
|
||||
await page.getByText('5 / 100').waitFor(); // Allocated to sales orders
|
||||
await page.getByText('10 / 125').waitFor(); // In production
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
// 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
|
||||
});
|
||||
|
||||
test('Parts - Allocations', async ({ browser }) => {
|
||||
@@ -377,7 +383,6 @@ test('Parts - Pricing (Supplier)', async ({ browser }) => {
|
||||
|
||||
// Supplier Pricing
|
||||
await page.getByRole('button', { name: 'Supplier Pricing' }).click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.getByRole('button', { name: 'SKU Not sorted' }).waitFor();
|
||||
|
||||
// Supplier Pricing - linkjumping
|
||||
|
||||
@@ -323,6 +323,7 @@ test('Stock - Return Items', async ({ browser }) => {
|
||||
name: 'action-menu-stock-operations-return-stock'
|
||||
})
|
||||
.click();
|
||||
|
||||
await page.getByText('#128').waitFor();
|
||||
await page.getByText('Merge into existing stock').waitFor();
|
||||
await page.getByRole('textbox', { name: 'number-field-quantity' }).fill('0');
|
||||
|
||||
@@ -52,8 +52,6 @@ test('Plugins - Settings', async ({ browser, request }) => {
|
||||
.fill(originalValue == '999' ? '1000' : '999');
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Change it back
|
||||
await page.getByLabel('edit-setting-NUMERICAL_SETTING').click();
|
||||
await page.getByLabel('number-field-value').fill(originalValue);
|
||||
@@ -164,8 +162,6 @@ test('Plugins - Panels', async ({ browser, request }) => {
|
||||
value: true
|
||||
});
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Ensure that the SampleUI plugin is enabled
|
||||
await setPluginState({
|
||||
request,
|
||||
@@ -173,8 +169,6 @@ test('Plugins - Panels', async ({ browser, request }) => {
|
||||
state: true
|
||||
});
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Navigate to the "part" page
|
||||
await navigate(page, 'part/69/');
|
||||
|
||||
@@ -186,20 +180,14 @@ test('Plugins - Panels', async ({ browser, request }) => {
|
||||
|
||||
// Check out each of the plugin panels
|
||||
await loadTab(page, 'Broken Panel');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await page.getByText('Error occurred while loading plugin content').waitFor();
|
||||
|
||||
await loadTab(page, 'Dynamic Panel');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await page.getByText('Instance ID: 69');
|
||||
await page
|
||||
.getByText('This panel has been dynamically rendered by the plugin system')
|
||||
.waitFor();
|
||||
|
||||
await loadTab(page, 'Part Panel');
|
||||
await page.waitForTimeout(500);
|
||||
await page.getByText('This content has been rendered by a custom plugin');
|
||||
|
||||
// Disable the plugin, and ensure it is no longer visible
|
||||
@@ -260,8 +248,6 @@ test('Plugins - Locate Item', async ({ browser, request }) => {
|
||||
state: true
|
||||
});
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Navigate to the "stock item" page
|
||||
await navigate(page, 'stock/item/287/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
@@ -273,7 +259,6 @@ test('Plugins - Locate Item', async ({ browser, request }) => {
|
||||
|
||||
// Show the location
|
||||
await page.getByLabel('breadcrumb-1-factory').click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await page.getByLabel('action-button-locate-item').click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
Reference in New Issue
Block a user