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:
BIN
docs/docs/assets/images/build/parts_allocated_consumed.png
Normal file
BIN
docs/docs/assets/images/build/parts_allocated_consumed.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 147 KiB |
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 4.2.15 on 2024-09-26 10:11
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('build', '0057_build_external'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='buildline',
|
||||||
|
name='consumed',
|
||||||
|
field=models.DecimalField(decimal_places=5, default=0, help_text='Quantity of consumed stock', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Consumed'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1106,7 +1106,7 @@ class Build(
|
|||||||
|
|
||||||
# Remove stock
|
# 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'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 }>) {
|
||||||
|
|||||||
@@ -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/',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user