2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-10-30 04:35:42 +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:
Oliver
2025-08-19 17:03:19 +10:00
committed by GitHub
parent ce6ffdac18
commit 49cc5fb137
24 changed files with 1079 additions and 142 deletions

View File

@@ -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

View File

@@ -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(),

View File

@@ -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'),
),
]

View File

@@ -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'
)

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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

View File

@@ -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 }>) {

View File

@@ -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/',

View File

@@ -60,6 +60,10 @@ export function RenderPartCategory(
): ReactNode {
const { instance } = props;
if (!instance) {
return '';
}
const suffix: ReactNode = (
<Group gap='xs'>
<TableHoverCard

View File

@@ -22,6 +22,10 @@ export function RenderStockLocation(
): ReactNode {
const { instance } = props;
if (!instance) {
return '';
}
const suffix: ReactNode = (
<Group gap='xs'>
<TableHoverCard

View File

@@ -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
};
})
}
});
}

View File

@@ -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>

View File

@@ -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
};

View File

@@ -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}

View File

@@ -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,

View File

@@ -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>
}

View File

@@ -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

View File

@@ -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'

View File

@@ -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

View File

@@ -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');

View File

@@ -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();