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" !!! 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 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 ## Completing a Build
!!! warning "Complete Build Outputs" !!! warning "Complete Build Outputs"

View File

@@ -1,11 +1,15 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
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 v385 -> 2025-08-15 : https://github.com/inventree/InvenTree/pull/10174
- Adjust return type of PurchaseOrderReceive API serializer - Adjust return type of PurchaseOrderReceive API serializer
- Now returns list of of the created stock items when receiving - 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__gte=F('quantity'))
return queryset.filter(allocated__lt=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( available = rest_filters.BooleanFilter(
label=_('Available'), method='filter_available' label=_('Available'), method='filter_available'
) )
@@ -494,6 +502,7 @@ class BuildLineFilter(rest_filters.FilterSet):
""" """
flt = Q( flt = Q(
quantity__lte=F('allocated') quantity__lte=F('allocated')
+ F('consumed')
+ F('available_stock') + F('available_stock')
+ F('available_substitute_stock') + F('available_substitute_stock')
+ F('available_variant_stock') + F('available_variant_stock')
@@ -504,7 +513,7 @@ class BuildLineFilter(rest_filters.FilterSet):
return queryset.exclude(flt) return queryset.exclude(flt)
class BuildLineEndpoint: class BuildLineMixin:
"""Mixin class for BuildLine API endpoints.""" """Mixin class for BuildLine API endpoints."""
queryset = BuildLine.objects.all() 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.""" """API endpoint for accessing a list of BuildLine objects."""
filterset_class = BuildLineFilter filterset_class = BuildLineFilter
@@ -562,6 +571,7 @@ class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI):
ordering_fields = [ ordering_fields = [
'part', 'part',
'allocated', 'allocated',
'consumed',
'reference', 'reference',
'quantity', 'quantity',
'consumable', 'consumable',
@@ -605,7 +615,7 @@ class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI):
return source_build return source_build
class BuildLineDetail(BuildLineEndpoint, RetrieveUpdateDestroyAPI): class BuildLineDetail(BuildLineMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a BuildLine object.""" """API endpoint for detail view of a BuildLine object."""
def get_source_build(self) -> Build | None: def get_source_build(self) -> Build | None:
@@ -734,6 +744,13 @@ class BuildAllocate(BuildOrderContextMixin, CreateAPI):
serializer_class = build.serializers.BuildAllocationSerializer 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): class BuildIssue(BuildOrderContextMixin, CreateAPI):
"""API endpoint for issuing a BuildOrder.""" """API endpoint for issuing a BuildOrder."""
@@ -953,6 +970,7 @@ build_api_urls = [
'<int:pk>/', '<int:pk>/',
include([ include([
path('allocate/', BuildAllocate.as_view(), name='api-build-allocate'), path('allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
path('consume/', BuildConsume.as_view(), name='api-build-consume'),
path( path(
'auto-allocate/', 'auto-allocate/',
BuildAutoAllocate.as_view(), 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 # Remove stock
for item in items: for item in items:
item.complete_allocation(user) item.complete_allocation(user=user)
# Delete allocation # Delete allocation
items.all().delete() items.all().delete()
@@ -1151,7 +1151,7 @@ class Build(
# Complete or discard allocations # Complete or discard allocations
for build_item in allocated_items: for build_item in allocated_items:
if not discard_allocations: if not discard_allocations:
build_item.complete_allocation(user) build_item.complete_allocation(user=user)
# Delete allocations # Delete allocations
allocated_items.delete() allocated_items.delete()
@@ -1200,7 +1200,7 @@ class Build(
for build_item in allocated_items: for build_item in allocated_items:
# Complete the allocation of stock for that item # 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 # Delete the BuildItem objects from the database
allocated_items.all().delete() allocated_items.all().delete()
@@ -1569,6 +1569,7 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo
build: Link to a Build object build: Link to a Build object
bom_item: Link to a BomItem object bom_item: Link to a BomItem object
quantity: Number of units required for the Build quantity: Number of units required for the Build
consumed: Number of units which have been consumed against this line item
""" """
class Meta: class Meta:
@@ -1614,6 +1615,15 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo
help_text=_('Required quantity for build order'), 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 @property
def part(self): def part(self):
"""Return the sub_part reference from the link bom_item.""" """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 True if this BuildLine is over-allocated."""
return self.allocated_quantity() > self.quantity 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): class BuildItem(InvenTree.models.InvenTreeMetadataModel):
"""A BuildItem links multiple StockItem objects to a Build. """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 return self.build_line.bom_item if self.build_line else None
@transaction.atomic @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. """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 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 - 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: 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 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 # Split the allocated stock if there are more available than allocated
if item.quantity > self.quantity: if item.quantity > quantity:
item = item.splitStock(self.quantity, None, user, notes=notes) item = item.splitStock(quantity, None, user, notes=notes)
# For a trackable part, special consideration needed! # For a trackable part, special consideration needed!
if item.part.trackable: if item.part.trackable:
@@ -1835,7 +1865,7 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel):
# Install the stock item into the output # Install the stock item into the output
self.install_into.installStockItem( self.install_into.installStockItem(
item, self.quantity, user, notes, build=self.build item, quantity, user, notes, build=self.build
) )
else: else:
@@ -1851,6 +1881,18 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel):
deltas={'buildorder': self.build.pk, 'quantity': float(item.quantity)}, 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( build_line = models.ForeignKey(
BuildLine, on_delete=models.CASCADE, null=True, related_name='allocations' BuildLine, on_delete=models.CASCADE, null=True, related_name='allocations'
) )

View File

@@ -171,6 +171,9 @@ class BuildSerializer(
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Determine if extra serializer fields are required.""" """Determine if extra serializer fields are required."""
part_detail = kwargs.pop('part_detail', True) 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) kwargs.pop('create', False)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -181,6 +184,15 @@ class BuildSerializer(
if not part_detail: if not part_detail:
self.fields.pop('part_detail', None) 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): def validate_reference(self, reference):
"""Custom validation for the Build reference field.""" """Custom validation for the Build reference field."""
# Ensure the reference matches the required pattern # Ensure the reference matches the required pattern
@@ -1000,7 +1012,7 @@ class BuildAllocationItemSerializer(serializers.Serializer):
class BuildAllocationSerializer(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: class Meta:
"""Serializer metaclass.""" """Serializer metaclass."""
@@ -1302,6 +1314,8 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
'build', 'build',
'bom_item', 'bom_item',
'quantity', 'quantity',
'consumed',
'allocations',
'part', 'part',
# Build detail fields # Build detail fields
'build_reference', 'build_reference',
@@ -1353,6 +1367,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
if not part_detail: if not part_detail:
self.fields.pop('part_detail', None) self.fields.pop('part_detail', None)
self.fields.pop('part_category_name', None)
if not build_detail: if not build_detail:
self.fields.pop('build_detail', None) self.fields.pop('build_detail', None)
@@ -1379,7 +1394,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
read_only=True, read_only=True,
) )
allocations = BuildItemSerializer(many=True, read_only=True) allocations = BuildItemSerializer(many=True, read_only=True, build_detail=False)
# BOM item info fields # BOM item info fields
reference = serializers.CharField( reference = serializers.CharField(
@@ -1405,6 +1420,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
) )
quantity = serializers.FloatField(label=_('Quantity')) quantity = serializers.FloatField(label=_('Quantity'))
consumed = serializers.FloatField(label=_('Consumed'))
bom_item = serializers.PrimaryKeyRelatedField(label=_('BOM Item'), read_only=True) bom_item = serializers.PrimaryKeyRelatedField(label=_('BOM Item'), read_only=True)
@@ -1437,17 +1453,23 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
read_only=True, read_only=True,
pricing=False, pricing=False,
) )
build_detail = BuildSerializer( build_detail = BuildSerializer(
label=_('Build'), label=_('Build'),
source='build', source='build',
part_detail=False,
many=False, many=False,
read_only=True, read_only=True,
allow_null=True, allow_null=True,
part_detail=False,
user_detail=False,
project_code_detail=False,
) )
# Annotated (calculated) fields # 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) on_order = serializers.FloatField(label=_('On Order'), read_only=True)
in_production = serializers.FloatField(label=_('In Production'), read_only=True) in_production = serializers.FloatField(label=_('In Production'), read_only=True)
scheduled_to_build = serializers.FloatField( scheduled_to_build = serializers.FloatField(
@@ -1498,6 +1520,9 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
'allocations__stock_item', 'allocations__stock_item',
'allocations__stock_item__part', 'allocations__stock_item__part',
'allocations__stock_item__location', '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',
'bom_item__sub_part__stock_items__allocations', 'bom_item__sub_part__stock_items__allocations',
'bom_item__sub_part__stock_items__sales_order_allocations', 'bom_item__sub_part__stock_items__sales_order_allocations',
@@ -1518,7 +1543,6 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
'build__destination', 'build__destination',
'build__take_from', 'build__take_from',
'build__completed_by', 'build__completed_by',
'build__issued_by',
'build__sales_order', 'build__sales_order',
'build__parent', 'build__parent',
'build__notes', 'build__notes',
@@ -1692,3 +1716,177 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
) )
return queryset 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) self.assertEqual(si.quantity, oq)
# Accept overallocated stock # Accept overallocated stock
# TODO: (2025-07-16) Look into optimizing this API query to reduce DB hits
self.post( self.post(
self.url, self.url,
{'accept_overallocated': 'accept'}, {'accept_overallocated': 'accept'},
expected_code=201, expected_code=201,
max_query_count=375, max_query_count=400,
) )
self.build.refresh_from_db() self.build.refresh_from_db()
@@ -1009,11 +1010,12 @@ class BuildOverallocationTest(BuildAPITest):
def test_overallocated_can_trim(self): def test_overallocated_can_trim(self):
"""Test build order will trim/de-allocate overallocated stock when requested.""" """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.post(
self.url, self.url,
{'accept_overallocated': 'trim'}, {'accept_overallocated': 'trim'},
expected_code=201, 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 # 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.assertGreater(n_f, 0)
self.assertEqual(n_t + n_f, BuildLine.objects.count()) 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 # Match BOM item to build
for bom_item in bom_items: 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 return quantity

View File

@@ -1,24 +1,31 @@
import { t } from '@lingui/core/macro'; 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'; import { isTrue } from '../functions/Conversion';
export function PassFailButton({ export function PassFailButton({
value, value,
passText, passText,
failText failText,
passColor,
failColor
}: Readonly<{ }: Readonly<{
value: any; value: any;
passText?: string; passText?: string;
failText?: string; failText?: string;
passColor?: MantineColor;
failColor?: MantineColor;
}>) { }>) {
const v = isTrue(value); const v = isTrue(value);
const pass = passText ?? t`Pass`; const pass = passText ?? t`Pass`;
const fail = failText ?? t`Fail`; const fail = failText ?? t`Fail`;
const pColor = passColor ?? 'green';
const fColor = failColor ?? 'red';
return ( return (
<Badge <Badge
color={v ? 'green' : 'red'} color={v ? pColor : fColor}
variant='filled' variant='filled'
radius='lg' radius='lg'
size='sm' size='sm'
@@ -30,7 +37,14 @@ export function PassFailButton({
} }
export function YesNoButton({ value }: Readonly<{ value: any }>) { 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 }>) { export function YesNoUndefinedButton({ value }: Readonly<{ value?: boolean }>) {

View File

@@ -96,6 +96,7 @@ export enum ApiEndpoints {
build_output_delete = 'build/:id/delete-outputs/', build_output_delete = 'build/:id/delete-outputs/',
build_order_auto_allocate = 'build/:id/auto-allocate/', build_order_auto_allocate = 'build/:id/auto-allocate/',
build_order_allocate = 'build/:id/allocate/', build_order_allocate = 'build/:id/allocate/',
build_order_consume = 'build/:id/consume/',
build_order_deallocate = 'build/:id/unallocate/', build_order_deallocate = 'build/:id/unallocate/',
build_line_list = 'build/line/', build_line_list = 'build/line/',

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
import { t } from '@lingui/core/macro'; 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 { import {
IconCalendar, IconCalendar,
IconCircleCheck,
IconInfoCircle, IconInfoCircle,
IconLink, IconLink,
IconList, IconList,
@@ -25,7 +26,10 @@ import {
type TableFieldRowProps type TableFieldRowProps
} from '../components/forms/fields/TableField'; } from '../components/forms/fields/TableField';
import { StatusRenderer } from '../components/render/StatusRenderer'; 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 { useCreateApiFormModal } from '../hooks/UseForm';
import { import {
useBatchCodeGenerator, useBatchCodeGenerator,
@@ -542,7 +546,7 @@ function BuildAllocateLineRow({
<Table.Td> <Table.Td>
<ProgressBar <ProgressBar
value={record.allocatedQuantity} value={record.allocatedQuantity}
maximum={record.requiredQuantity} maximum={record.requiredQuantity - record.consumed}
progressLabel progressLabel
/> />
</Table.Td> </Table.Td>
@@ -670,15 +674,220 @@ export function useAllocateStockToBuildForm({
successMessage: t`Stock items allocated`, successMessage: t`Stock items allocated`,
onFormSuccess: onFormSuccess, onFormSuccess: onFormSuccess,
initialData: { initialData: {
items: lineItems.map((item) => { items: lineItems
return { .filter((item) => {
build_line: item.pk, return item.requiredQuantity > item.allocatedQuantity + item.consumed;
stock_item: undefined, })
quantity: Math.max(0, item.requiredQuantity - item.allocatedQuantity), .map((item) => {
output: outputId return {
}; build_line: item.pk,
}) stock_item: undefined,
quantity: Math.max(
0,
item.requiredQuantity - item.allocatedQuantity - item.consumed
),
output: outputId
};
})
}, },
size: '80%' 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 { Thumbnail } from '../components/images/Thumbnail';
import { StylishText } from '../components/items/StylishText'; import { StylishText } from '../components/items/StylishText';
import { StatusRenderer } from '../components/render/StatusRenderer'; import { StatusRenderer } from '../components/render/StatusRenderer';
import { RenderStockLocation } from '../components/render/Stock';
import { InvenTreeIcon } from '../functions/icons'; import { InvenTreeIcon } from '../functions/icons';
import { import {
useApiFormModal, useApiFormModal,
@@ -576,7 +577,7 @@ function StockOperationsRow({
</Stack> </Stack>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
{record.location ? record.location_detail?.pathstring : '-'} <RenderStockLocation instance={record.location_detail} />
</Table.Td> </Table.Td>
<Table.Td>{record.batch ? record.batch : '-'}</Table.Td> <Table.Td>{record.batch ? record.batch : '-'}</Table.Td>
<Table.Td> <Table.Td>

View File

@@ -2,7 +2,7 @@
* Common rendering functions for table column data. * Common rendering functions for table column data.
*/ */
import { t } from '@lingui/core/macro'; 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 { import {
IconBell, IconBell,
IconExclamationCircle, IconExclamationCircle,
@@ -210,7 +210,9 @@ export function BooleanColumn(props: TableColumn): TableColumn {
sortable: true, sortable: true,
switchable: true, switchable: true,
render: (record: any) => ( render: (record: any) => (
<YesNoButton value={resolveItem(record, props.accessor ?? '')} /> <Center>
<YesNoButton value={resolveItem(record, props.accessor ?? '')} />
</Center>
), ),
...props ...props
}; };

View File

@@ -10,8 +10,11 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType'; import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles'; import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import { ActionButton } from '@lib/index';
import type { TableFilter } from '@lib/types/Filters'; import type { TableFilter } from '@lib/types/Filters';
import type { TableColumn } from '@lib/types/Tables'; 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 type { StockOperationProps } from '../../forms/StockForms';
import { import {
useDeleteApiFormModal, useDeleteApiFormModal,
@@ -160,10 +163,10 @@ export default function BuildAllocatedStockTable({
]; ];
}, []); }, []);
const [selectedItem, setSelectedItem] = useState<number>(0); const [selectedItemId, setSelectedItemId] = useState<number>(0);
const editItem = useEditApiFormModal({ const editItem = useEditApiFormModal({
pk: selectedItem, pk: selectedItemId,
url: ApiEndpoints.build_item_list, url: ApiEndpoints.build_item_list,
title: t`Edit Stock Allocation`, title: t`Edit Stock Allocation`,
fields: { fields: {
@@ -176,12 +179,23 @@ export default function BuildAllocatedStockTable({
}); });
const deleteItem = useDeleteApiFormModal({ const deleteItem = useDeleteApiFormModal({
pk: selectedItem, pk: selectedItemId,
url: ApiEndpoints.build_item_list, url: ApiEndpoints.build_item_list,
title: t`Delete Stock Allocation`, title: t`Delete Stock Allocation`,
table: table table: table
}); });
const [selectedItems, setSelectedItems] = useState<any[]>([]);
const consumeStock = useConsumeBuildItemsForm({
buildId: buildId ?? 0,
allocatedItems: selectedItems,
onFormSuccess: () => {
table.clearSelectedRecords();
table.refreshTable();
}
});
const stockOperationProps: StockOperationProps = useMemo(() => { const stockOperationProps: StockOperationProps = useMemo(() => {
// Extract stock items from the selected records // Extract stock items from the selected records
// Note that the table is actually a list of BuildItem instances, // Note that the table is actually a list of BuildItem instances,
@@ -216,17 +230,28 @@ export default function BuildAllocatedStockTable({
const rowActions = useCallback( const rowActions = useCallback(
(record: any): RowAction[] => { (record: any): RowAction[] => {
return [ return [
{
color: 'green',
icon: <IconCircleDashedCheck />,
title: t`Consume`,
tooltip: t`Consume Stock`,
hidden: !user.hasChangeRole(UserRoles.build),
onClick: () => {
setSelectedItems([record]);
consumeStock.open();
}
},
RowEditAction({ RowEditAction({
hidden: !user.hasChangeRole(UserRoles.build), hidden: !user.hasChangeRole(UserRoles.build),
onClick: () => { onClick: () => {
setSelectedItem(record.pk); setSelectedItemId(record.pk);
editItem.open(); editItem.open();
} }
}), }),
RowDeleteAction({ RowDeleteAction({
hidden: !user.hasDeleteRole(UserRoles.build), hidden: !user.hasDeleteRole(UserRoles.build),
onClick: () => { onClick: () => {
setSelectedItem(record.pk); setSelectedItemId(record.pk);
deleteItem.open(); deleteItem.open();
} }
}) })
@@ -236,13 +261,28 @@ export default function BuildAllocatedStockTable({
); );
const tableActions = useMemo(() => { const tableActions = useMemo(() => {
return [stockAdjustActions.dropdown]; return [
}, [stockAdjustActions.dropdown]); 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 ( return (
<> <>
{editItem.modal} {editItem.modal}
{deleteItem.modal} {deleteItem.modal}
{consumeStock.modal}
{stockAdjustActions.modals.map((modal) => modal.modal)} {stockAdjustActions.modals.map((modal) => modal.modal)}
<InvenTreeTable <InvenTreeTable
tableState={table} tableState={table}

View File

@@ -1,21 +1,21 @@
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { Alert, Group, Paper, Stack, Text } from '@mantine/core'; import { Alert, Group, Paper, Text } from '@mantine/core';
import { import {
IconArrowRight, IconArrowRight,
IconCircleCheck,
IconCircleDashedCheck,
IconCircleMinus, IconCircleMinus,
IconShoppingCart, IconShoppingCart,
IconTool, IconTool,
IconWand IconWand
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { DataTable, type DataTableRowExpansionProps } from 'mantine-datatable'; import type { DataTableRowExpansionProps } from 'mantine-datatable';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { ActionButton } from '@lib/components/ActionButton'; import { ActionButton } from '@lib/components/ActionButton';
import { ProgressBar } from '@lib/components/ProgressBar'; import { ProgressBar } from '@lib/components/ProgressBar';
import { import {
type RowAction,
RowActions,
RowDeleteAction, RowDeleteAction,
RowEditAction, RowEditAction,
RowViewAction RowViewAction
@@ -26,11 +26,12 @@ import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import { formatDecimal } from '@lib/functions/Formatting'; import { formatDecimal } from '@lib/functions/Formatting';
import type { TableFilter } from '@lib/types/Filters'; 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 OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
import { import {
useAllocateStockToBuildForm, useAllocateStockToBuildForm,
useBuildOrderFields useBuildOrderFields,
useConsumeBuildLinesForm
} from '../../forms/BuildForms'; } from '../../forms/BuildForms';
import { import {
useCreateApiFormModal, useCreateApiFormModal,
@@ -70,6 +71,7 @@ export function BuildLineSubTable({
}>) { }>) {
const user = useUserState(); const user = useUserState();
const navigate = useNavigate(); const navigate = useNavigate();
const table = useTable('buildline-subtable');
const tableColumns: any[] = useMemo(() => { const tableColumns: any[] = useMemo(() => {
return [ return [
@@ -96,59 +98,52 @@ export function BuildLineSubTable({
}, },
LocationColumn({ LocationColumn({
accessor: 'location_detail' 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 ( return (
<Paper p='md'> <Paper p='xs'>
<Stack gap='xs'> <InvenTreeTable
<DataTable tableState={table}
minHeight={50} columns={tableColumns}
withTableBorder tableData={lineItem.filteredAllocations ?? lineItem.allocations}
withColumnBorders props={{
striped minHeight: 200,
pinLastColumn enableSearch: false,
idAccessor='pk' enableRefresh: false,
columns={tableColumns} enableColumnSwitching: false,
records={lineItem.filteredAllocations ?? lineItem.allocations} enableFilters: false,
/> rowActions: rowActions,
</Stack> noRecordsText: ''
}}
/>
</Paper> </Paper>
); );
} }
@@ -186,7 +181,12 @@ export default function BuildLineTable({
{ {
name: 'allocated', name: 'allocated',
label: t`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', name: 'available',
@@ -471,13 +471,56 @@ export default function BuildLineTable({
switchable: false, switchable: false,
sortable: true, sortable: true,
hidden: !isActive, 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) => { render: (record: any) => {
return record?.bom_item_detail?.consumable ? ( return record?.bom_item_detail?.consumable ? (
<Text style={{ fontStyle: 'italic' }}>{t`Consumable item`}</Text> <Text style={{ fontStyle: 'italic' }}>{t`Consumable item`}</Text>
) : ( ) : (
<ProgressBar <ProgressBar
progressLabel={true} progressLabel={true}
value={record.allocatedQuantity} value={record.consumed}
maximum={record.requiredQuantity} maximum={record.requiredQuantity}
/> />
); );
@@ -544,6 +587,7 @@ export default function BuildLineTable({
buildId: build.pk, buildId: build.pk,
lineItems: selectedRows, lineItems: selectedRows,
onFormSuccess: () => { onFormSuccess: () => {
table.clearSelectedRecords();
table.refreshTable(); table.refreshTable();
} }
}); });
@@ -574,7 +618,10 @@ export default function BuildLineTable({
</Alert> </Alert>
), ),
successMessage: t`Stock has been deallocated`, successMessage: t`Stock has been deallocated`,
table: table onFormSuccess: () => {
table.clearSelectedRecords();
table.refreshTable();
}
}); });
const [selectedAllocation, setSelectedAllocation] = useState<number>(0); const [selectedAllocation, setSelectedAllocation] = useState<number>(0);
@@ -605,6 +652,15 @@ export default function BuildLineTable({
parts: partsToOrder parts: partsToOrder
}); });
const consumeLines = useConsumeBuildLinesForm({
buildId: build.pk,
buildLines: selectedRows,
onFormSuccess: () => {
table.clearSelectedRecords();
table.refreshTable();
}
});
const rowActions = useCallback( const rowActions = useCallback(
(record: any): RowAction[] => { (record: any): RowAction[] => {
const part = record.part_detail ?? {}; const part = record.part_detail ?? {};
@@ -613,11 +669,24 @@ export default function BuildLineTable({
const hasOutput = !!output?.pk; 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 // Can allocate
const canAllocate = const canAllocate =
in_production && in_production &&
!consumable && !consumable &&
user.hasChangeRole(UserRoles.build) && user.hasChangeRole(UserRoles.build) &&
required > 0 &&
record.trackable == hasOutput; record.trackable == hasOutput;
// Can de-allocate // Can de-allocate
@@ -647,6 +716,16 @@ export default function BuildLineTable({
allocateStock.open(); allocateStock.open();
} }
}, },
{
icon: <IconCircleDashedCheck />,
title: t`Consume Stock`,
color: 'green',
hidden: !canConsume || hasOutput,
onClick: () => {
setSelectedRows([record]);
consumeLines.open();
}
},
{ {
icon: <IconCircleMinus />, icon: <IconCircleMinus />,
title: t`Deallocate Stock`, title: t`Deallocate Stock`,
@@ -758,6 +837,18 @@ export default function BuildLineTable({
setSelectedLine(null); setSelectedLine(null);
deallocateStock.open(); 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} {deallocateStock.modal}
{editAllocation.modal} {editAllocation.modal}
{deleteAllocation.modal} {deleteAllocation.modal}
{consumeLines.modal}
{orderPartsWizard.wizard} {orderPartsWizard.wizard}
<InvenTreeTable <InvenTreeTable
url={apiUrl(ApiEndpoints.build_line_list)} url={apiUrl(ApiEndpoints.build_line_list)}
@@ -852,6 +944,7 @@ export default function BuildLineTable({
params: { params: {
...params, ...params,
build: build.pk, build: build.pk,
assembly_detail: false,
part_detail: true part_detail: true
}, },
tableActions: tableActions, tableActions: tableActions,

View File

@@ -95,20 +95,24 @@ function OutputAllocationDrawer({
position='bottom' position='bottom'
size='lg' size='lg'
title={ 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> <StylishText size='lg'>{t`Build Output Stock Allocation`}</StylishText>
<Space h='lg' /> <Space h='lg' />
<PartColumn part={build.part_detail} /> <Paper withBorder p='sm'>
{output?.serial && ( <Group gap='xs'>
<Text size='sm'> <PartColumn part={build.part_detail} />
{t`Serial Number`}: {output.serial} {output?.serial && (
</Text> <Text size='sm'>
)} {t`Serial Number`}: {output.serial}
{output?.batch && ( </Text>
<Text size='sm'> )}
{t`Batch Code`}: {output.batch} {output?.batch && (
</Text> <Text size='sm'>
)} {t`Batch Code`}: {output.batch}
</Text>
)}
</Group>
</Paper>
<Space h='lg' /> <Space h='lg' />
</Group> </Group>
} }

View File

@@ -12,6 +12,7 @@ import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import type { TableFilter } from '@lib/types/Filters'; import type { TableFilter } from '@lib/types/Filters';
import type { TableColumn } from '@lib/types/Tables'; import type { TableColumn } from '@lib/types/Tables';
import { IconCircleCheck } from '@tabler/icons-react';
import { useTable } from '../../hooks/UseTable'; import { useTable } from '../../hooks/UseTable';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
import { import {
@@ -92,13 +93,30 @@ export default function PartBuildAllocationsTable({
sortable: true, sortable: true,
switchable: false, switchable: false,
title: t`Required Stock`, title: t`Required Stock`,
render: (record: any) => ( render: (record: any) => {
<ProgressBar const required = Math.max(0, record.quantity - record.consumed);
progressLabel
value={record.allocated} if (required <= 0) {
maximum={record.quantity} 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]); }, [table.isRowExpanded]);
@@ -142,11 +160,13 @@ export default function PartBuildAllocationsTable({
tableState={table} tableState={table}
columns={tableColumns} columns={tableColumns}
props={{ props={{
minHeight: 200, minHeight: 300,
params: { params: {
part: partId, part: partId,
consumable: false, consumable: false,
part_detail: true, part_detail: false,
bom_item_detail: false,
project_code_detail: true,
assembly_detail: true, assembly_detail: true,
build_detail: true, build_detail: true,
order_outstanding: true order_outstanding: true

View File

@@ -350,6 +350,59 @@ test('Build Order - Allocation', async ({ browser }) => {
.waitFor(); .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 }) => { test('Build Order - Tracked Outputs', async ({ browser }) => {
const page = await doCachedLogin(browser, { const page = await doCachedLogin(browser, {
url: 'manufacturing/build-order/10/incomplete-outputs' 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('5 / 100').waitFor(); // Allocated to sales orders
await page.getByText('10 / 125').waitFor(); // In production 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 }) => { test('Parts - Allocations', async ({ browser }) => {
@@ -377,7 +383,6 @@ test('Parts - Pricing (Supplier)', async ({ browser }) => {
// Supplier Pricing // Supplier Pricing
await page.getByRole('button', { name: 'Supplier Pricing' }).click(); await page.getByRole('button', { name: 'Supplier Pricing' }).click();
await page.waitForTimeout(500);
await page.getByRole('button', { name: 'SKU Not sorted' }).waitFor(); await page.getByRole('button', { name: 'SKU Not sorted' }).waitFor();
// Supplier Pricing - linkjumping // Supplier Pricing - linkjumping

View File

@@ -323,6 +323,7 @@ test('Stock - Return Items', async ({ browser }) => {
name: 'action-menu-stock-operations-return-stock' name: 'action-menu-stock-operations-return-stock'
}) })
.click(); .click();
await page.getByText('#128').waitFor(); await page.getByText('#128').waitFor();
await page.getByText('Merge into existing stock').waitFor(); await page.getByText('Merge into existing stock').waitFor();
await page.getByRole('textbox', { name: 'number-field-quantity' }).fill('0'); 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'); .fill(originalValue == '999' ? '1000' : '999');
await page.getByRole('button', { name: 'Submit' }).click(); await page.getByRole('button', { name: 'Submit' }).click();
await page.waitForTimeout(500);
// Change it back // Change it back
await page.getByLabel('edit-setting-NUMERICAL_SETTING').click(); await page.getByLabel('edit-setting-NUMERICAL_SETTING').click();
await page.getByLabel('number-field-value').fill(originalValue); await page.getByLabel('number-field-value').fill(originalValue);
@@ -164,8 +162,6 @@ test('Plugins - Panels', async ({ browser, request }) => {
value: true value: true
}); });
await page.waitForTimeout(500);
// Ensure that the SampleUI plugin is enabled // Ensure that the SampleUI plugin is enabled
await setPluginState({ await setPluginState({
request, request,
@@ -173,8 +169,6 @@ test('Plugins - Panels', async ({ browser, request }) => {
state: true state: true
}); });
await page.waitForTimeout(500);
// Navigate to the "part" page // Navigate to the "part" page
await navigate(page, 'part/69/'); await navigate(page, 'part/69/');
@@ -186,20 +180,14 @@ test('Plugins - Panels', async ({ browser, request }) => {
// Check out each of the plugin panels // Check out each of the plugin panels
await loadTab(page, 'Broken Panel'); await loadTab(page, 'Broken Panel');
await page.waitForTimeout(500);
await page.getByText('Error occurred while loading plugin content').waitFor(); await page.getByText('Error occurred while loading plugin content').waitFor();
await loadTab(page, 'Dynamic Panel'); await loadTab(page, 'Dynamic Panel');
await page.waitForTimeout(500);
await page.getByText('Instance ID: 69'); await page.getByText('Instance ID: 69');
await page await page
.getByText('This panel has been dynamically rendered by the plugin system') .getByText('This panel has been dynamically rendered by the plugin system')
.waitFor(); .waitFor();
await loadTab(page, 'Part Panel'); await loadTab(page, 'Part Panel');
await page.waitForTimeout(500);
await page.getByText('This content has been rendered by a custom plugin'); await page.getByText('This content has been rendered by a custom plugin');
// Disable the plugin, and ensure it is no longer visible // Disable the plugin, and ensure it is no longer visible
@@ -260,8 +248,6 @@ test('Plugins - Locate Item', async ({ browser, request }) => {
state: true state: true
}); });
await page.waitForTimeout(500);
// Navigate to the "stock item" page // Navigate to the "stock item" page
await navigate(page, 'stock/item/287/'); await navigate(page, 'stock/item/287/');
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
@@ -273,7 +259,6 @@ test('Plugins - Locate Item', async ({ browser, request }) => {
// Show the location // Show the location
await page.getByLabel('breadcrumb-1-factory').click(); await page.getByLabel('breadcrumb-1-factory').click();
await page.waitForTimeout(500);
await page.getByLabel('action-button-locate-item').click(); await page.getByLabel('action-button-locate-item').click();
await page.getByRole('button', { name: 'Submit' }).click(); await page.getByRole('button', { name: 'Submit' }).click();