2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-10-29 12:27:41 +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

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

View File

@@ -126,6 +126,49 @@ Here we can see that the incomplete build outputs (serial numbers 15 and 14) now
!!! note "Example: Tracked Stock"
Let's say we have 5 units of "Tracked Part" in stock - with 1 unit allocated to the build output. Once we complete the build output, there will be 4 units of "Tracked Part" in stock, with 1 unit being marked as "installed" within the assembled part
## Consuming Stock
Allocating stock items to a build order does not immediately remove them from stock. Instead, the stock items are marked as "allocated" against the build order, and are only removed from stock when they are "consumed" by the build order.
In the *Required Parts* tab, you can see the *consumed* vs *allocated* state of each line item in the BOM:
{{ image("build/parts_allocated_consumed.png", "Partially allocated and consumed") }}
Consuming items against the build order can be performed in two ways:
- Manually, by consuming selected stock allocations against the build order
- Automatically, by completing the build order
### Manual Consumption
Manual consuming stock items (before the build order is completed) can be performed at any point after stock has been allocated against the build order. Manual stock consumption may be desired in some situations, for example if the build order is being performed in stages, or to ensure that stock levels are kept up to date.
Manual consumption of stock items can be performed in the in the following ways:
#### Required Parts Tab
Consuming stock items can be performed against BOM line items in the *Required Parts* tab, either against a single line or multiple selected lines:
- Navigate to the *Required Parts* tab
- Select the individual line items which you wish to consume
- Click the *Consume Stock* button
#### Allocated Stock Tab
Consuming stock items can also be performed against the *Allocated Stock* tab, either against a single allocation or multiple allocations:
- Navigate to the *Allocated Stock* tab
- Select the individual stock allocations which you wish to consume
- Click the *Consume Stock* button
### Automatic Consumption
When a build order is completed, all remaining allocated stock items are automatically consumed by the build order.
### Returning Items to Stock
Consumed items may be manually returned into stock if required. This can be performed in the *Consumed Stock* tab.
## Completing a Build
!!! warning "Complete Build Outputs"

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,11 +674,18 @@ export function useAllocateStockToBuildForm({
successMessage: t`Stock items allocated`,
onFormSuccess: onFormSuccess,
initialData: {
items: lineItems.map((item) => {
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),
quantity: Math.max(
0,
item.requiredQuantity - item.allocatedQuantity - item.consumed
),
output: outputId
};
})
@@ -682,3 +693,201 @@ export function useAllocateStockToBuildForm({
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) => (
<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,17 +98,13 @@ export function BuildLineSubTable({
},
LocationColumn({
accessor: 'location_detail'
}),
{
accessor: '---actions---',
title: ' ',
width: 50,
render: (record: any) => {
return (
<RowActions
title={t`Actions`}
index={record.pk}
actions={[
})
];
}, []);
const rowActions = useCallback(
(record: any): RowAction[] => {
return [
RowViewAction({
title: t`View Stock Item`,
modelType: ModelType.stockitem,
@@ -114,41 +112,38 @@ export function BuildLineSubTable({
navigate: navigate
}),
RowEditAction({
hidden:
!onEditAllocation || !user.hasChangeRole(UserRoles.build),
hidden: !onEditAllocation || !user.hasChangeRole(UserRoles.build),
onClick: () => {
onEditAllocation?.(record.pk);
}
}),
RowDeleteAction({
hidden:
!onDeleteAllocation || !user.hasDeleteRole(UserRoles.build),
hidden: !onDeleteAllocation || !user.hasDeleteRole(UserRoles.build),
onClick: () => {
onDeleteAllocation?.(record.pk);
}
})
]}
/>
);
}
}
];
}, [user, onEditAllocation, onDeleteAllocation]);
},
[user, onEditAllocation, onDeleteAllocation]
);
return (
<Paper p='md'>
<Stack gap='xs'>
<DataTable
minHeight={50}
withTableBorder
withColumnBorders
striped
pinLastColumn
idAccessor='pk'
<Paper p='xs'>
<InvenTreeTable
tableState={table}
columns={tableColumns}
records={lineItem.filteredAllocations ?? lineItem.allocations}
tableData={lineItem.filteredAllocations ?? lineItem.allocations}
props={{
minHeight: 200,
enableSearch: false,
enableRefresh: false,
enableColumnSwitching: false,
enableFilters: false,
rowActions: rowActions,
noRecordsText: ''
}}
/>
</Stack>
</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,9 +95,11 @@ 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' />
<Paper withBorder p='sm'>
<Group gap='xs'>
<PartColumn part={build.part_detail} />
{output?.serial && (
<Text size='sm'>
@@ -109,6 +111,8 @@ function OutputAllocationDrawer({
{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) => (
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={record.quantity}
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();