mirror of
https://github.com/inventree/InvenTree.git
synced 2026-07-17 20:23:50 +00:00
[feature] Break apart assemblies (#12310)
* Implement API endpoint for stock disassembly * Add basic frontend implementation * Adjust required user permission * Add preFormContent * Read-only if serialized * Adjust location and status of each subcomponent * Handle null value * display installed items in frontend form * More unit tests * Traceability * Add docs * Exclude virtual / consumable stock * Additional docs * Added bundled items docs * More docs * more docs tweaks * Updated part docs * Add playwright tests * Robustify test * suppress certain warnings in schema * Adjust playwright tests * bug fix * Tweak playwright tests
This commit is contained in:
@@ -1,11 +1,15 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 519
|
||||
INVENTREE_API_VERSION = 520
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v520 -> 2026-07-11 : https://github.com/inventree/InvenTree/pull/xxxx
|
||||
- Adds new "disassemble" API endpoint for stock items
|
||||
- Allows a stock item to be broken down into component parts, based on its Bill of Materials
|
||||
|
||||
v519 -> 2026-07-09 : https://github.com/inventree/InvenTree/pull/TODO
|
||||
- Adds optional "roles" and "permissions" fields to the /user/me/ API endpoint, via the "?roles=true" query parameter
|
||||
|
||||
|
||||
@@ -223,12 +223,18 @@ def postprocess_schema_enums(result, generator, **kwargs):
|
||||
"""Custom patch to ignore some drf-spectacular warnings.
|
||||
|
||||
- Some warnings are unavoidable due to the way that InvenTree implements generic relationships (via ContentType).
|
||||
- Some warnings are unavoidable due to the way that InvenTree implements custom (database-editable) status codes:
|
||||
multiple serializers legitimately expose a 'status' field backed by the same dynamic StockStatus choice set
|
||||
(e.g. stock adjustment, receiving a purchase order line, disassembling a stock item), and drf-spectacular
|
||||
cannot settle on a single stable name for the shared, runtime-dependent choice set.
|
||||
- The cleanest way to handle this appears to be to override the 'warn' function from drf-spectacular.
|
||||
|
||||
Ref: https://github.com/inventree/InvenTree/pull/10699
|
||||
"""
|
||||
ignore_patterns = [
|
||||
'enum naming encountered a non-optimally resolvable collision for fields named "model_type"'
|
||||
'enum naming encountered a non-optimally resolvable collision for fields named "model_type"',
|
||||
'enum naming encountered a non-optimally resolvable collision for fields named "status"',
|
||||
'encountered multiple names for the same choice set (StatusCustomKeyEnum)',
|
||||
]
|
||||
|
||||
if any(pattern in msg for pattern in ignore_patterns):
|
||||
|
||||
@@ -173,6 +173,36 @@ class StockItemConvert(StockItemContextMixin, CreateAPI):
|
||||
serializer_class = StockSerializers.ConvertStockItemSerializer
|
||||
|
||||
|
||||
@extend_schema(responses={201: StockSerializers.StockItemSerializer(many=True)})
|
||||
class StockItemDisassemble(StockItemContextMixin, CreateAPI):
|
||||
"""API endpoint for disassembling a stock item into its component parts.
|
||||
|
||||
The components are generated based on the BOM lines provided in the request,
|
||||
which must be valid BOM lines for the part associated with the stock item.
|
||||
"""
|
||||
|
||||
serializer_class = StockSerializers.DisassembleStockItemSerializer
|
||||
pagination_class = None
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""Disassemble the provided StockItem."""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Perform the actual disassembly step
|
||||
items = serializer.save()
|
||||
|
||||
queryset = StockSerializers.StockItemSerializer.annotate_queryset(
|
||||
StockItem.objects.filter(pk__in=[item.pk for item in items])
|
||||
)
|
||||
|
||||
response = StockSerializers.StockItemSerializer(
|
||||
queryset, many=True, context=self.get_serializer_context()
|
||||
)
|
||||
|
||||
return Response(response.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class StockAdjustView(CreateAPI):
|
||||
"""A generic class for handling stocktake actions.
|
||||
|
||||
@@ -1771,6 +1801,11 @@ stock_api_urls = [
|
||||
'<int:pk>/',
|
||||
include([
|
||||
path('convert/', StockItemConvert.as_view(), name='api-stock-item-convert'),
|
||||
path(
|
||||
'disassemble/',
|
||||
StockItemDisassemble.as_view(),
|
||||
name='api-stock-item-disassemble',
|
||||
),
|
||||
path('install/', StockItemInstall.as_view(), name='api-stock-item-install'),
|
||||
meta_path(StockItem),
|
||||
path(
|
||||
|
||||
@@ -15,5 +15,6 @@ class StockEvents(BaseEventEnum):
|
||||
ITEM_COUNTED = 'stockitem.counted'
|
||||
ITEM_QUANTITY_UPDATED = 'stockitem.quantityupdated'
|
||||
ITEM_INSTALLED_INTO_ASSEMBLY = 'stockitem.installed'
|
||||
ITEM_DISASSEMBLED = 'stockitem.disassembled'
|
||||
|
||||
ITEMS_CREATED = 'stockitem.created_items'
|
||||
|
||||
@@ -1813,6 +1813,276 @@ class StockItem(
|
||||
|
||||
self.save()
|
||||
|
||||
def allocate_disassembly_pricing(self, quantity, lines: list) -> list:
|
||||
"""Allocate the purchase price of this stock item across disassembly lines.
|
||||
|
||||
Automatic cost allocation is only performed if:
|
||||
- This stock item has a recorded purchase price
|
||||
- None of the provided lines specify an explicit purchase price
|
||||
|
||||
The total cost is apportioned across the lines pro-rata,
|
||||
weighted by existing pricing data for each component part.
|
||||
If pricing data is not available for *every* line,
|
||||
the cost is split evenly across the generated units.
|
||||
|
||||
Args:
|
||||
quantity: The number of units of this stock item being disassembled
|
||||
lines: A list of dict objects (see the disassemble method)
|
||||
|
||||
Returns:
|
||||
The list of lines, with 'purchase_price' values filled in where possible
|
||||
"""
|
||||
if not self.purchase_price:
|
||||
return lines
|
||||
|
||||
if any(line.get('purchase_price') is not None for line in lines):
|
||||
# Explicit pricing has been provided - do not override
|
||||
return lines
|
||||
|
||||
# Calculate a relative pricing "weight" for each line
|
||||
weights = []
|
||||
|
||||
for line in lines:
|
||||
pricing = line['bom_item'].sub_part.pricing
|
||||
|
||||
price_range = [
|
||||
price
|
||||
for price in [pricing.overall_min, pricing.overall_max]
|
||||
if price is not None
|
||||
]
|
||||
|
||||
weight = None
|
||||
|
||||
if price_range:
|
||||
mid = sum(price_range[1:], price_range[0]) / len(price_range)
|
||||
|
||||
if mid.amount > 0:
|
||||
weight = Decimal(mid.amount) * Decimal(line['quantity'])
|
||||
|
||||
weights.append(weight)
|
||||
|
||||
if any(weight is None for weight in weights):
|
||||
# Insufficient pricing data - fall back to an even (per unit) split
|
||||
weights = [Decimal(line['quantity']) for line in lines]
|
||||
|
||||
total_weight = sum(weights)
|
||||
|
||||
if total_weight <= 0:
|
||||
return lines
|
||||
|
||||
# Total cost of the disassembled units
|
||||
total_cost = self.purchase_price * quantity
|
||||
|
||||
for line, weight in zip(lines, weights, strict=True):
|
||||
line_quantity = Decimal(line['quantity'])
|
||||
|
||||
if line_quantity > 0:
|
||||
if weight is None:
|
||||
# Fallback to an even split
|
||||
line['purchase_price'] = total_cost / len(lines) / line_quantity
|
||||
else:
|
||||
line['purchase_price'] = (
|
||||
total_cost * (weight / total_weight) / line_quantity
|
||||
)
|
||||
|
||||
return lines
|
||||
|
||||
@transaction.atomic
|
||||
def disassemble(
|
||||
self, quantity, lines: list, user, location=None, notes: str = ''
|
||||
) -> list[StockItem]:
|
||||
"""Disassemble this stock item into its component parts.
|
||||
|
||||
The provided BOM lines determine which component parts are "broken out"
|
||||
of this stock item. A new stock item is created for each line,
|
||||
and the quantity of this item is reduced accordingly.
|
||||
|
||||
Args:
|
||||
quantity: The number of units of this stock item to disassemble
|
||||
lines: A list of dict objects, each containing:
|
||||
bom_item: The BomItem instance defining the component to break out
|
||||
quantity: The total quantity of the component to create
|
||||
location: Optional destination StockLocation for the component
|
||||
status: Optional StockStatus code for the component (default: OK)
|
||||
purchase_price: Optional unit purchase price for the component
|
||||
user: The user performing the operation
|
||||
location: Default destination location for the generated components
|
||||
notes: Optional transaction notes
|
||||
|
||||
Returns:
|
||||
A list of the component StockItem objects (created or uninstalled)
|
||||
|
||||
Note:
|
||||
This stock item is *retained* (with reduced quantity) even if the
|
||||
quantity reaches zero, to preserve traceability of the disassembly.
|
||||
|
||||
Note:
|
||||
Any stock items which are *installed* within this stock item are
|
||||
broken out (uninstalled) as part of the disassembly operation,
|
||||
rather than creating new stock items for them.
|
||||
|
||||
Note:
|
||||
Traceability data is passed down from this stock item to the
|
||||
generated components: the batch code and source purchase order
|
||||
are copied across, while any source build order is recorded
|
||||
in the stock tracking history of each component.
|
||||
"""
|
||||
try:
|
||||
quantity = Decimal(quantity)
|
||||
except (InvalidOperation, ValueError):
|
||||
raise ValidationError({'quantity': _('Invalid quantity provided')})
|
||||
|
||||
if quantity <= 0:
|
||||
raise ValidationError({'quantity': _('Quantity must be greater than zero')})
|
||||
|
||||
if quantity > self.quantity:
|
||||
raise ValidationError({
|
||||
'quantity': _('Quantity must not exceed available stock quantity')
|
||||
})
|
||||
|
||||
if self.serialized and quantity != self.quantity:
|
||||
raise ValidationError({
|
||||
'quantity': _(
|
||||
'Serialized stock item must be disassembled in its entirety'
|
||||
)
|
||||
})
|
||||
|
||||
if len(lines) == 0:
|
||||
raise ValidationError(_('No BOM lines provided'))
|
||||
|
||||
# Any items installed within this stock item *must* be broken out,
|
||||
# which requires that the entire quantity is disassembled
|
||||
installed_items = list(self.installed_parts.all())
|
||||
|
||||
if len(installed_items) > 0 and quantity != self.quantity:
|
||||
raise ValidationError({
|
||||
'quantity': _(
|
||||
'Stock item with installed items must be disassembled in its entirety'
|
||||
)
|
||||
})
|
||||
|
||||
# Cache the custom status options for the StockItem model
|
||||
custom_status_values = StockItem.STATUS_CLASS.custom_values()
|
||||
|
||||
items = []
|
||||
|
||||
# Match installed items against the provided BOM lines,
|
||||
# so that the correct location and status values can be applied
|
||||
line_map = {}
|
||||
|
||||
for line in lines:
|
||||
for part in line['bom_item'].get_valid_parts_for_allocation():
|
||||
line_map.setdefault(part.pk, line)
|
||||
|
||||
for installed_item in installed_items:
|
||||
line = line_map.get(installed_item.part.pk)
|
||||
|
||||
destination = (
|
||||
(line.get('location') if line else None) or location or self.location
|
||||
)
|
||||
|
||||
if line:
|
||||
# The uninstalled quantity reduces the quantity of new items
|
||||
# to be created against the matching BOM line
|
||||
line['quantity'] = max(
|
||||
Decimal(0), Decimal(line['quantity']) - installed_item.quantity
|
||||
)
|
||||
|
||||
if status := line.get('status'):
|
||||
installed_item.set_status(
|
||||
status, custom_values=custom_status_values
|
||||
)
|
||||
|
||||
# Break the installed item out into the destination location
|
||||
# Note: this also saves any status change applied above
|
||||
installed_item.uninstall_into_location(destination, user, notes)
|
||||
|
||||
items.append(installed_item)
|
||||
|
||||
# Allocate pricing data across the generated components.
|
||||
# Note: Deliberately performed *after* the installed items are uninstalled,
|
||||
# so that the cost is only spread across newly created stock items
|
||||
lines = self.allocate_disassembly_pricing(quantity, lines)
|
||||
|
||||
for line in lines:
|
||||
bom_item = line['bom_item']
|
||||
line_quantity = Decimal(line['quantity'])
|
||||
|
||||
if line_quantity <= 0:
|
||||
continue
|
||||
|
||||
destination = line.get('location') or location or self.location
|
||||
|
||||
new_item = StockItem(
|
||||
part=bom_item.sub_part,
|
||||
quantity=line_quantity,
|
||||
location=destination,
|
||||
parent=self,
|
||||
batch=self.batch,
|
||||
purchase_order=self.purchase_order,
|
||||
purchase_price=line.get('purchase_price', None),
|
||||
)
|
||||
|
||||
if status := line.get('status'):
|
||||
new_item.set_status(status, custom_values=custom_status_values)
|
||||
|
||||
# Ensure the tree structure is observed
|
||||
new_item.tree_id = None
|
||||
new_item.save(add_note=False)
|
||||
|
||||
deltas = {'stockitem': self.pk, 'quantity': float(line_quantity)}
|
||||
|
||||
# Record traceability links against the disassembled assembly
|
||||
if self.build:
|
||||
deltas['buildorder'] = self.build.pk
|
||||
|
||||
if self.purchase_order:
|
||||
deltas['purchaseorder'] = self.purchase_order.pk
|
||||
|
||||
new_item.add_tracking_entry(
|
||||
StockHistoryCode.CREATED_FROM_DISASSEMBLY,
|
||||
user,
|
||||
notes=notes,
|
||||
deltas=deltas,
|
||||
location=destination,
|
||||
)
|
||||
|
||||
items.append(new_item)
|
||||
|
||||
# Consume this stock item.
|
||||
# Note: the item is deliberately retained (not deleted) at zero quantity,
|
||||
# to preserve the disassembly history
|
||||
if self.serialized:
|
||||
# A serialized item cannot be reduced to zero quantity,
|
||||
# so instead it is marked as "destroyed"
|
||||
deltas = {
|
||||
'removed': float(quantity),
|
||||
'status': StockStatus.DESTROYED.value,
|
||||
'old_status': self.status,
|
||||
}
|
||||
|
||||
self.set_status(StockStatus.DESTROYED.value)
|
||||
else:
|
||||
deltas = {
|
||||
'removed': float(quantity),
|
||||
'quantity': float(self.quantity - quantity),
|
||||
}
|
||||
|
||||
self.quantity = self.quantity - quantity
|
||||
|
||||
self.save(add_note=False)
|
||||
|
||||
self.add_tracking_entry(
|
||||
StockHistoryCode.DISASSEMBLED, user, notes=notes, deltas=deltas
|
||||
)
|
||||
|
||||
# Rebuild the stock tree for this item
|
||||
stock.tasks.rebuild_stock_item_tree(self.tree_id)
|
||||
|
||||
trigger_event(StockEvents.ITEM_DISASSEMBLED, id=self.pk)
|
||||
|
||||
return items
|
||||
|
||||
@property
|
||||
def children(self):
|
||||
"""Return a list of the child items which have been split from this stock item."""
|
||||
@@ -1922,7 +2192,7 @@ class StockItem(
|
||||
return None
|
||||
|
||||
# Extract any additional information from the kwargs
|
||||
self._apply_model_reference_fields(deltas, kwargs)
|
||||
self._apply_model_reference_fields(kwargs, deltas)
|
||||
|
||||
# Quantity specified?
|
||||
quantity = kwargs.get('quantity')
|
||||
|
||||
@@ -945,6 +945,227 @@ class UninstallStockItemSerializer(serializers.Serializer):
|
||||
item.uninstall_into_location(location, request.user, note)
|
||||
|
||||
|
||||
class StockStatusCustomSerializer(serializers.ChoiceField):
|
||||
"""Serializer to allow annotating the schema to use int where custom values may be entered."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize the status selector."""
|
||||
if 'choices' not in kwargs:
|
||||
kwargs['choices'] = stock.status_codes.StockStatus.items(custom=True)
|
||||
|
||||
if 'label' not in kwargs:
|
||||
kwargs['label'] = _('Status')
|
||||
|
||||
if 'help_text' not in kwargs:
|
||||
kwargs['help_text'] = _('Stock item status code')
|
||||
|
||||
if InvenTree.ready.isGeneratingSchema():
|
||||
kwargs['help_text'] = (
|
||||
kwargs['help_text']
|
||||
+ '\n\n'
|
||||
+ '\n'.join(
|
||||
f'* `{value}` - {label}' for value, label in kwargs['choices']
|
||||
)
|
||||
+ "\n\nAdditional custom status keys may be retrieved from the 'stock_status_retrieve' call."
|
||||
)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class DisassemblyLineSerializer(serializers.Serializer):
|
||||
"""Serializer for a single component line in a stock disassembly operation."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
fields = [
|
||||
'bom_item',
|
||||
'quantity',
|
||||
'location',
|
||||
'status',
|
||||
'purchase_price',
|
||||
'purchase_price_currency',
|
||||
]
|
||||
|
||||
bom_item = serializers.PrimaryKeyRelatedField(
|
||||
queryset=part_models.BomItem.objects.all(),
|
||||
many=False,
|
||||
required=True,
|
||||
allow_null=False,
|
||||
label=_('BOM Item'),
|
||||
help_text=_('BOM line which defines the component part to break out'),
|
||||
)
|
||||
|
||||
quantity = serializers.DecimalField(
|
||||
max_digits=15,
|
||||
decimal_places=5,
|
||||
min_value=Decimal(0),
|
||||
required=True,
|
||||
label=_('Quantity'),
|
||||
help_text=_('Quantity of the component part to create'),
|
||||
)
|
||||
|
||||
def validate_quantity(self, quantity):
|
||||
"""Validation for the 'quantity' field."""
|
||||
if quantity <= 0:
|
||||
raise ValidationError(_('Quantity must be greater than zero'))
|
||||
|
||||
return quantity
|
||||
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockLocation.objects.all(),
|
||||
many=False,
|
||||
allow_null=True,
|
||||
required=False,
|
||||
label=_('Location'),
|
||||
help_text=_('Destination location for the component items'),
|
||||
)
|
||||
|
||||
status = StockStatusCustomSerializer(
|
||||
required=False, help_text=_('Status code for the component items')
|
||||
)
|
||||
|
||||
purchase_price = InvenTree.serializers.InvenTreeMoneySerializer(
|
||||
label=_('Purchase Price'),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None,
|
||||
help_text=_(
|
||||
'Unit purchase price for the component items (leave blank for automatic cost allocation)'
|
||||
),
|
||||
)
|
||||
|
||||
purchase_price_currency = InvenTreeCurrencySerializer(
|
||||
required=False, help_text=_('Purchase price currency')
|
||||
)
|
||||
|
||||
|
||||
class DisassembleStockItemSerializer(serializers.Serializer):
|
||||
"""DRF serializer class for disassembling a StockItem into component parts.
|
||||
|
||||
Note: The stock item being disassembled is provided via the serializer context
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
fields = ['items', 'quantity', 'location', 'notes']
|
||||
|
||||
items = DisassemblyLineSerializer(many=True)
|
||||
|
||||
quantity = serializers.DecimalField(
|
||||
max_digits=15,
|
||||
decimal_places=5,
|
||||
min_value=Decimal(0),
|
||||
required=True,
|
||||
label=_('Quantity'),
|
||||
help_text=_('Number of assemblies to disassemble'),
|
||||
)
|
||||
|
||||
def validate_quantity(self, quantity):
|
||||
"""Validation for the 'quantity' field."""
|
||||
item = self.context.get('item')
|
||||
|
||||
if not item:
|
||||
raise ValidationError(_('No stock item provided'))
|
||||
|
||||
if quantity <= 0:
|
||||
raise ValidationError(_('Quantity must be greater than zero'))
|
||||
|
||||
if quantity > item.quantity:
|
||||
raise ValidationError(
|
||||
_('Quantity must not exceed available stock quantity')
|
||||
+ f' ({item.quantity})'
|
||||
)
|
||||
|
||||
return quantity
|
||||
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockLocation.objects.all(),
|
||||
many=False,
|
||||
allow_null=True,
|
||||
required=False,
|
||||
label=_('Location'),
|
||||
help_text=_('Default destination location for the component items'),
|
||||
)
|
||||
|
||||
notes = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
label=_('Notes'),
|
||||
help_text=_('Optional note field'),
|
||||
)
|
||||
|
||||
def validate(self, data):
|
||||
"""Validate the disassembly operation.
|
||||
|
||||
- The stock item must be "in stock"
|
||||
- Each line must reference a valid BOM item for the part
|
||||
- Each BOM item may only be referenced once
|
||||
"""
|
||||
data = super().validate(data)
|
||||
|
||||
item = self.context.get('item')
|
||||
|
||||
if not item:
|
||||
raise ValidationError(_('No stock item provided'))
|
||||
|
||||
if not item.in_stock:
|
||||
raise ValidationError(_('Stock item is unavailable'))
|
||||
|
||||
items = data.get('items', [])
|
||||
|
||||
if len(items) == 0:
|
||||
raise ValidationError(_('Line items must be provided'))
|
||||
|
||||
# The set of valid BOM items for this part.
|
||||
# Consumable BOM lines (and lines pointing to a virtual part) are excluded,
|
||||
# as these components are not expected to be tracked as physical stock
|
||||
bom_items = item.part.get_bom_items(include_virtual=False).filter(
|
||||
part_models.BomItem.consumable_filter(consumable=False)
|
||||
)
|
||||
|
||||
bom_item_pks = set()
|
||||
|
||||
for line in items:
|
||||
bom_item = line['bom_item']
|
||||
|
||||
if not bom_items.filter(pk=bom_item.pk).exists():
|
||||
raise ValidationError(
|
||||
_('BOM item is not valid for the selected stock item')
|
||||
)
|
||||
|
||||
if bom_item.pk in bom_item_pks:
|
||||
raise ValidationError(_('Duplicate BOM items provided'))
|
||||
|
||||
bom_item_pks.add(bom_item.pk)
|
||||
|
||||
return data
|
||||
|
||||
def save(self) -> list[StockItem]:
|
||||
"""Disassemble the provided StockItem into its component parts.
|
||||
|
||||
Returns:
|
||||
A list of StockItem objects created by the disassembly operation.
|
||||
"""
|
||||
item = self.context['item']
|
||||
request = self.context.get('request')
|
||||
|
||||
data = self.validated_data
|
||||
|
||||
try:
|
||||
return item.disassemble(
|
||||
data['quantity'],
|
||||
data['items'],
|
||||
request.user if request else None,
|
||||
location=data.get('location', None),
|
||||
notes=data.get('notes', ''),
|
||||
)
|
||||
except DjangoValidationError as exc:
|
||||
# Catch model errors and re-throw as DRF errors
|
||||
raise ValidationError(detail=serializers.as_serializer_error(exc))
|
||||
|
||||
|
||||
class ConvertStockItemSerializer(serializers.Serializer):
|
||||
"""DRF serializer class for converting a StockItem to a valid variant part."""
|
||||
|
||||
@@ -1008,33 +1229,6 @@ class ConvertStockItemSerializer(serializers.Serializer):
|
||||
help_text='Status key, chosen from the list of StockStatus keys'
|
||||
)
|
||||
)
|
||||
class StockStatusCustomSerializer(serializers.ChoiceField):
|
||||
"""Serializer to allow annotating the schema to use int where custom values may be entered."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize the status selector."""
|
||||
if 'choices' not in kwargs:
|
||||
kwargs['choices'] = stock.status_codes.StockStatus.items(custom=True)
|
||||
|
||||
if 'label' not in kwargs:
|
||||
kwargs['label'] = _('Status')
|
||||
|
||||
if 'help_text' not in kwargs:
|
||||
kwargs['help_text'] = _('Stock item status code')
|
||||
|
||||
if InvenTree.ready.isGeneratingSchema():
|
||||
kwargs['help_text'] = (
|
||||
kwargs['help_text']
|
||||
+ '\n\n'
|
||||
+ '\n'.join(
|
||||
f'* `{value}` - {label}' for value, label in kwargs['choices']
|
||||
)
|
||||
+ "\n\nAdditional custom status keys may be retrieved from the 'stock_status_retrieve' call."
|
||||
)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class StockChangeStatusSerializer(serializers.Serializer):
|
||||
"""Serializer for changing status of multiple StockItem objects."""
|
||||
|
||||
|
||||
@@ -75,6 +75,10 @@ class StockHistoryCode(StatusCode):
|
||||
# Stock merging operations
|
||||
MERGED_STOCK_ITEMS = 45, _('Merged stock items')
|
||||
|
||||
# Stock disassembly operations
|
||||
DISASSEMBLED = 46, _('Disassembled into components')
|
||||
CREATED_FROM_DISASSEMBLY = 47, _('Created from disassembly')
|
||||
|
||||
# Convert stock item to variant
|
||||
CONVERTED_TO_VARIANT = 48, _('Converted to variant')
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from rest_framework import status
|
||||
|
||||
import build.models
|
||||
import company.models
|
||||
import order.models
|
||||
import part.models
|
||||
from common.models import InvenTreeCustomUserStateModel, InvenTreeSetting
|
||||
from common.settings import set_global_setting
|
||||
@@ -2215,6 +2216,487 @@ class StockItemTest(StockAPITestCase):
|
||||
self.assertEqual(item.batch, 'NEW-BATCH-CODE')
|
||||
|
||||
|
||||
class StockItemDisassembleTest(StockAPITestCase):
|
||||
"""Series of API tests for the StockItem disassembly endpoint."""
|
||||
|
||||
def setUp(self):
|
||||
"""Create a stock item of an assembly part, ready for disassembly."""
|
||||
super().setUp()
|
||||
|
||||
# Part 100 ("Bob") is an assembly with 4 BOM lines
|
||||
self.assembly = part.models.Part.objects.get(pk=100)
|
||||
|
||||
self.item = StockItem.objects.create(
|
||||
part=self.assembly,
|
||||
quantity=10,
|
||||
location=StockLocation.objects.get(pk=1),
|
||||
purchase_price=Money(100, 'USD'),
|
||||
)
|
||||
|
||||
self.url = reverse('api-stock-item-disassemble', kwargs={'pk': self.item.pk})
|
||||
|
||||
def test_validation(self):
|
||||
"""Test validation checks for the disassembly endpoint."""
|
||||
# Empty request
|
||||
response = self.post(self.url, {}, expected_code=400)
|
||||
|
||||
self.assertIn('This field is required', str(response.data['items']))
|
||||
self.assertIn('This field is required', str(response.data['quantity']))
|
||||
|
||||
# No line items provided
|
||||
response = self.post(self.url, {'items': [], 'quantity': 1}, expected_code=400)
|
||||
|
||||
self.assertIn('Line items must be provided', str(response.data))
|
||||
|
||||
# Quantity exceeds available stock
|
||||
response = self.post(
|
||||
self.url,
|
||||
{'items': [{'bom_item': 1, 'quantity': 10}], 'quantity': 100},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
'Quantity must not exceed available stock quantity', str(response.data)
|
||||
)
|
||||
|
||||
# BOM item which points to a different part (BomItem pk=5 -> part 1)
|
||||
response = self.post(
|
||||
self.url,
|
||||
{'items': [{'bom_item': 5, 'quantity': 1}], 'quantity': 1},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
'BOM item is not valid for the selected stock item', str(response.data)
|
||||
)
|
||||
|
||||
# Duplicated BOM items
|
||||
response = self.post(
|
||||
self.url,
|
||||
{
|
||||
'items': [
|
||||
{'bom_item': 1, 'quantity': 10},
|
||||
{'bom_item': 1, 'quantity': 5},
|
||||
],
|
||||
'quantity': 1,
|
||||
},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn('Duplicate BOM items provided', str(response.data))
|
||||
|
||||
def test_consumable_and_virtual_excluded(self):
|
||||
"""Consumable BOM lines, and lines pointing to a virtual part, cannot be disassembled."""
|
||||
# BOM line marked as consumable directly
|
||||
consumable_line = part.models.BomItem.objects.create(
|
||||
part=self.assembly,
|
||||
sub_part=part.models.Part.objects.get(pk=2),
|
||||
quantity=1,
|
||||
consumable=True,
|
||||
)
|
||||
|
||||
# BOM line whose sub_part is marked as consumable
|
||||
consumable_part = part.models.Part.objects.get(pk=3)
|
||||
consumable_part.consumable = True
|
||||
consumable_part.save()
|
||||
|
||||
# BOM line whose sub_part is marked as virtual
|
||||
virtual_part = part.models.Part.objects.get(pk=5)
|
||||
virtual_part.virtual = True
|
||||
virtual_part.save()
|
||||
|
||||
for bom_item_pk in [consumable_line.pk, 2, 3]:
|
||||
response = self.post(
|
||||
self.url,
|
||||
{'items': [{'bom_item': bom_item_pk, 'quantity': 1}], 'quantity': 1},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
'BOM item is not valid for the selected stock item', str(response.data)
|
||||
)
|
||||
|
||||
def test_disassemble(self):
|
||||
"""Test a valid disassembly operation, using a subset of the BOM lines."""
|
||||
n = StockItem.objects.count()
|
||||
|
||||
# Disassemble 4 assemblies, but only break out 2 of the 4 BOM lines
|
||||
# The second line specifies a custom location and status
|
||||
response = self.post(
|
||||
self.url,
|
||||
{
|
||||
'items': [
|
||||
{'bom_item': 1, 'quantity': 40},
|
||||
{
|
||||
'bom_item': 4,
|
||||
'quantity': 12,
|
||||
'location': 2,
|
||||
'status': StockStatus.DAMAGED.value,
|
||||
},
|
||||
],
|
||||
'quantity': 4,
|
||||
'notes': 'Breaking apart',
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 2)
|
||||
self.assertEqual(StockItem.objects.count(), n + 2)
|
||||
|
||||
self.item.refresh_from_db()
|
||||
self.assertEqual(self.item.quantity, 6)
|
||||
|
||||
items = {entry['part']: entry for entry in response.data}
|
||||
|
||||
# Check the generated component items
|
||||
item_1 = StockItem.objects.get(pk=items[1]['pk'])
|
||||
item_50 = StockItem.objects.get(pk=items[50]['pk'])
|
||||
|
||||
self.assertEqual(item_1.quantity, 40)
|
||||
self.assertEqual(item_50.quantity, 12)
|
||||
|
||||
# Components inherit the location of the disassembled item by default
|
||||
self.assertEqual(item_1.location, self.item.location)
|
||||
self.assertEqual(item_1.status, StockStatus.OK.value)
|
||||
|
||||
# A custom location and status can be specified per line
|
||||
self.assertEqual(item_50.location.pk, 2)
|
||||
self.assertEqual(item_50.status, StockStatus.DAMAGED.value)
|
||||
|
||||
# Components are linked to the disassembled item
|
||||
self.assertEqual(item_1.parent.pk, self.item.pk)
|
||||
self.assertEqual(item_50.parent.pk, self.item.pk)
|
||||
|
||||
# Total cost (4 x 100 USD) is allocated evenly across 52 units
|
||||
self.assertAlmostEqual(float(item_1.purchase_price.amount), 400 / 52, places=4)
|
||||
self.assertAlmostEqual(float(item_50.purchase_price.amount), 400 / 52, places=4)
|
||||
self.assertEqual(str(item_1.purchase_price.currency), 'USD')
|
||||
|
||||
# Check stock tracking entries have been created
|
||||
self.assertTrue(
|
||||
self.item.tracking_info.filter(
|
||||
tracking_type=StockHistoryCode.DISASSEMBLED.value
|
||||
).exists()
|
||||
)
|
||||
|
||||
for item in [item_1, item_50]:
|
||||
entry = item.tracking_info.filter(
|
||||
tracking_type=StockHistoryCode.CREATED_FROM_DISASSEMBLY.value
|
||||
).first()
|
||||
|
||||
self.assertIsNotNone(entry)
|
||||
self.assertEqual(entry.deltas['stockitem'], self.item.pk)
|
||||
|
||||
def test_full_disassembly(self):
|
||||
"""Test that a fully disassembled item is retained at zero quantity."""
|
||||
self.post(
|
||||
self.url,
|
||||
{'items': [{'bom_item': 2, 'quantity': 400}], 'quantity': 10},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
self.item.refresh_from_db()
|
||||
|
||||
self.assertEqual(self.item.quantity, 0)
|
||||
self.assertFalse(self.item.in_stock)
|
||||
|
||||
def test_traceability_inheritance(self):
|
||||
"""Test that traceability data is passed down to the generated components."""
|
||||
po = order.models.PurchaseOrder.objects.create(
|
||||
supplier=company.models.Company.objects.get(pk=1), reference='PO-9999'
|
||||
)
|
||||
|
||||
bo = build.models.Build.objects.create(
|
||||
part=self.assembly, quantity=10, reference='BO-9999'
|
||||
)
|
||||
|
||||
self.item.batch = 'ABC-123'
|
||||
self.item.purchase_order = po
|
||||
self.item.build = bo
|
||||
self.item.save()
|
||||
|
||||
response = self.post(
|
||||
self.url,
|
||||
{'items': [{'bom_item': 1, 'quantity': 10}], 'quantity': 1},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
component = StockItem.objects.get(pk=response.data[0]['pk'])
|
||||
|
||||
# Batch code and source purchase order are inherited directly
|
||||
self.assertEqual(component.batch, 'ABC-123')
|
||||
self.assertEqual(component.purchase_order, po)
|
||||
|
||||
# The source build order cannot be copied across (the component is not
|
||||
# an output of the build) - instead it is recorded in the stock history
|
||||
self.assertIsNone(component.build)
|
||||
|
||||
entry = component.tracking_info.filter(
|
||||
tracking_type=StockHistoryCode.CREATED_FROM_DISASSEMBLY.value
|
||||
).first()
|
||||
|
||||
self.assertIsNotNone(entry)
|
||||
self.assertEqual(entry.deltas['buildorder'], bo.pk)
|
||||
self.assertEqual(entry.deltas['purchaseorder'], po.pk)
|
||||
|
||||
def test_explicit_pricing(self):
|
||||
"""Test that automatic cost allocation is skipped if explicit pricing is provided."""
|
||||
response = self.post(
|
||||
self.url,
|
||||
{
|
||||
'items': [
|
||||
{
|
||||
'bom_item': 1,
|
||||
'quantity': 10,
|
||||
'purchase_price': 5,
|
||||
'purchase_price_currency': 'NZD',
|
||||
},
|
||||
{'bom_item': 4, 'quantity': 3},
|
||||
],
|
||||
'quantity': 1,
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
prices = {entry['part']: entry['purchase_price'] for entry in response.data}
|
||||
|
||||
self.assertAlmostEqual(prices[1], 5, places=3)
|
||||
self.assertIsNone(prices[50])
|
||||
|
||||
def install_item(self, part_pk: int, quantity, **kwargs) -> StockItem:
|
||||
"""Install a new stock item into the assembly under test."""
|
||||
return StockItem.objects.create(
|
||||
part=part.models.Part.objects.get(pk=part_pk),
|
||||
quantity=quantity,
|
||||
belongs_to=self.item,
|
||||
location=None,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def test_installed_items_partial(self):
|
||||
"""Installed items are uninstalled, and only the remainder is created afresh."""
|
||||
sub_1 = self.install_item(1, 12)
|
||||
sub_2 = self.install_item(1, 8)
|
||||
|
||||
n = StockItem.objects.count()
|
||||
|
||||
# Disassemble all 10 assemblies - the line requires 100 units in total
|
||||
response = self.post(
|
||||
self.url,
|
||||
{'items': [{'bom_item': 1, 'quantity': 100}], 'quantity': 10},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
# Two uninstalled items, plus a single new item for the remainder
|
||||
self.assertEqual(len(response.data), 3)
|
||||
self.assertEqual(StockItem.objects.count(), n + 1)
|
||||
|
||||
pks = {entry['pk'] for entry in response.data}
|
||||
self.assertIn(sub_1.pk, pks)
|
||||
self.assertIn(sub_2.pk, pks)
|
||||
|
||||
for sub in [sub_1, sub_2]:
|
||||
sub.refresh_from_db()
|
||||
self.assertIsNone(sub.belongs_to)
|
||||
self.assertEqual(sub.location, self.item.location)
|
||||
|
||||
new_item = StockItem.objects.get(pk=(pks - {sub_1.pk, sub_2.pk}).pop())
|
||||
self.assertEqual(new_item.part.pk, 1)
|
||||
self.assertEqual(new_item.quantity, 80)
|
||||
|
||||
# Uninstalled items are updated, not created afresh
|
||||
self.assertTrue(
|
||||
sub_1.tracking_info.filter(
|
||||
tracking_type=StockHistoryCode.REMOVED_FROM_ASSEMBLY.value
|
||||
).exists()
|
||||
)
|
||||
self.assertFalse(
|
||||
sub_1.tracking_info.filter(
|
||||
tracking_type=StockHistoryCode.CREATED_FROM_DISASSEMBLY.value
|
||||
).exists()
|
||||
)
|
||||
self.assertTrue(
|
||||
self.item.tracking_info.filter(
|
||||
tracking_type=StockHistoryCode.REMOVED_CHILD_ITEM.value
|
||||
).exists()
|
||||
)
|
||||
self.assertTrue(
|
||||
new_item.tracking_info.filter(
|
||||
tracking_type=StockHistoryCode.CREATED_FROM_DISASSEMBLY.value
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_installed_items_full_coverage(self):
|
||||
"""No new stock item is created for a line fully covered by installed items."""
|
||||
sub = self.install_item(1, 120, purchase_price=Money(3, 'USD'))
|
||||
|
||||
n = StockItem.objects.count()
|
||||
|
||||
response = self.post(
|
||||
self.url,
|
||||
{
|
||||
'items': [
|
||||
{'bom_item': 1, 'quantity': 100},
|
||||
{'bom_item': 4, 'quantity': 30},
|
||||
],
|
||||
'quantity': 10,
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
# The first line is fully covered by the uninstalled item
|
||||
self.assertEqual(len(response.data), 2)
|
||||
self.assertEqual(StockItem.objects.count(), n + 1)
|
||||
|
||||
sub.refresh_from_db()
|
||||
self.assertIsNone(sub.belongs_to)
|
||||
|
||||
# The uninstalled item retains its own purchase price
|
||||
self.assertEqual(sub.purchase_price, Money(3, 'USD'))
|
||||
|
||||
# The total cost (10 x 100 USD) is spread only across newly created units
|
||||
new_item = StockItem.objects.get(part=50, parent=self.item)
|
||||
self.assertEqual(new_item.quantity, 30)
|
||||
self.assertAlmostEqual(
|
||||
float(new_item.purchase_price.amount), 1000 / 30, places=4
|
||||
)
|
||||
|
||||
def test_installed_items_leftover(self):
|
||||
"""Installed items which do not match a BOM line are still uninstalled."""
|
||||
# Part 2 is not a component of the assembly
|
||||
sub = self.install_item(2, 5, status=StockStatus.ATTENTION.value)
|
||||
|
||||
response = self.post(
|
||||
self.url,
|
||||
{
|
||||
'items': [{'bom_item': 2, 'quantity': 400}],
|
||||
'quantity': 10,
|
||||
'location': 2,
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 2)
|
||||
|
||||
sub.refresh_from_db()
|
||||
self.assertIsNone(sub.belongs_to)
|
||||
|
||||
# Uninstalled to the top-level destination, retaining its own status
|
||||
self.assertEqual(sub.location.pk, 2)
|
||||
self.assertEqual(sub.status, StockStatus.ATTENTION.value)
|
||||
|
||||
# The BOM line quantity is not reduced by the unmatched item
|
||||
new_item = StockItem.objects.get(part=3, parent=self.item)
|
||||
self.assertEqual(new_item.quantity, 400)
|
||||
|
||||
def test_installed_items_require_full_disassembly(self):
|
||||
"""Partial disassembly is rejected while items are installed."""
|
||||
self.install_item(1, 1)
|
||||
|
||||
response = self.post(
|
||||
self.url,
|
||||
{'items': [{'bom_item': 1, 'quantity': 50}], 'quantity': 5},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn('must be disassembled in its entirety', str(response.data))
|
||||
|
||||
def test_installed_items_status_and_location(self):
|
||||
"""Explicit per-line status and location values are applied to uninstalled items."""
|
||||
sub_1 = self.install_item(1, 10)
|
||||
sub_2 = self.install_item(50, 4, status=StockStatus.ATTENTION.value)
|
||||
|
||||
self.post(
|
||||
self.url,
|
||||
{
|
||||
'items': [
|
||||
{
|
||||
'bom_item': 1,
|
||||
'quantity': 100,
|
||||
'location': 2,
|
||||
'status': StockStatus.DAMAGED.value,
|
||||
},
|
||||
{'bom_item': 4, 'quantity': 30},
|
||||
],
|
||||
'quantity': 10,
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
sub_1.refresh_from_db()
|
||||
self.assertEqual(sub_1.location.pk, 2)
|
||||
self.assertEqual(sub_1.status, StockStatus.DAMAGED.value)
|
||||
|
||||
# No explicit status provided for the second line - item status is retained
|
||||
sub_2.refresh_from_db()
|
||||
self.assertEqual(sub_2.location, self.item.location)
|
||||
self.assertEqual(sub_2.status, StockStatus.ATTENTION.value)
|
||||
|
||||
def test_installed_items_variants_and_substitutes(self):
|
||||
"""Installed variant and substitute parts are matched against BOM lines."""
|
||||
# Designate part 2 as a substitute for BOM line 4
|
||||
part.models.BomItemSubstitute.objects.create(
|
||||
bom_item=part.models.BomItem.objects.get(pk=4),
|
||||
part=part.models.Part.objects.get(pk=2),
|
||||
)
|
||||
|
||||
# New BOM line against a template part, allowing variants
|
||||
bom_line = part.models.BomItem.objects.create(
|
||||
part=self.assembly,
|
||||
sub_part=part.models.Part.objects.get(pk=10000),
|
||||
quantity=5,
|
||||
allow_variants=True,
|
||||
)
|
||||
|
||||
substitute_item = self.install_item(2, 10)
|
||||
variant_item = self.install_item(10001, 20)
|
||||
|
||||
response = self.post(
|
||||
self.url,
|
||||
{
|
||||
'items': [
|
||||
{'bom_item': 4, 'quantity': 30},
|
||||
{'bom_item': bom_line.pk, 'quantity': 50},
|
||||
],
|
||||
'quantity': 10,
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 4)
|
||||
|
||||
for sub in [substitute_item, variant_item]:
|
||||
sub.refresh_from_db()
|
||||
self.assertIsNone(sub.belongs_to)
|
||||
|
||||
# Line quantities are reduced by the matched installed items
|
||||
new_50 = StockItem.objects.get(part=50, parent=self.item)
|
||||
self.assertEqual(new_50.quantity, 20)
|
||||
|
||||
new_chair = StockItem.objects.get(part=10000, parent=self.item)
|
||||
self.assertEqual(new_chair.quantity, 30)
|
||||
|
||||
def test_serialized(self):
|
||||
"""Test disassembly of a serialized stock item."""
|
||||
item = StockItem.objects.create(
|
||||
part=self.assembly, quantity=1, serial='SN-DISASSEMBLE-1'
|
||||
)
|
||||
|
||||
url = reverse('api-stock-item-disassemble', kwargs={'pk': item.pk})
|
||||
|
||||
self.post(
|
||||
url,
|
||||
{'items': [{'bom_item': 1, 'quantity': 10}], 'quantity': 1},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
item.refresh_from_db()
|
||||
|
||||
# A serialized item cannot be "depleted" - it is marked as destroyed instead
|
||||
self.assertEqual(item.status, StockStatus.DESTROYED.value)
|
||||
self.assertFalse(item.in_stock)
|
||||
|
||||
|
||||
class StocktakeTest(StockAPITestCase):
|
||||
"""Series of tests for the Stocktake API."""
|
||||
|
||||
|
||||
@@ -155,6 +155,7 @@ export enum ApiEndpoints {
|
||||
stock_assign = 'stock/assign/',
|
||||
stock_status = 'stock/status/',
|
||||
stock_convert = 'stock/:id/convert/',
|
||||
stock_disassemble = 'stock/:id/disassemble/',
|
||||
stock_install = 'stock/:id/install/',
|
||||
stock_uninstall = 'stock/:id/uninstall/',
|
||||
stock_serialize = 'stock/:id/serialize/',
|
||||
|
||||
@@ -6,16 +6,19 @@ import { InvenTreeIcon } from '../../functions/icons';
|
||||
|
||||
export default function RemoveRowButton({
|
||||
onClick,
|
||||
disabled,
|
||||
tooltip = t`Remove this row`,
|
||||
tooltipAlignment
|
||||
}: Readonly<{
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
tooltip?: string;
|
||||
tooltipAlignment?: FloatingPosition;
|
||||
}>) {
|
||||
return (
|
||||
<ActionButton
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
icon={<InvenTreeIcon icon='square_x' />}
|
||||
tooltip={tooltip}
|
||||
tooltipAlignment={tooltipAlignment ?? 'top-end'}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { StylishText } from '@lib/components/StylishText';
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { formatDecimal } from '@lib/functions/Formatting';
|
||||
import { getDetailUrl } from '@lib/functions/Navigation';
|
||||
import type {
|
||||
ApiFormAdjustFilterType,
|
||||
@@ -14,18 +15,24 @@ import type {
|
||||
import { t } from '@lingui/core/macro';
|
||||
import {
|
||||
Alert,
|
||||
Collapse,
|
||||
Flex,
|
||||
Group,
|
||||
List,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Table,
|
||||
Text
|
||||
Text,
|
||||
UnstyledButton
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { modals } from '@mantine/modals';
|
||||
import {
|
||||
IconCalendarExclamation,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconCoins,
|
||||
IconCurrencyDollar,
|
||||
IconLink,
|
||||
@@ -37,6 +44,7 @@ import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
type JSX,
|
||||
type ReactNode,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -44,7 +52,7 @@ import {
|
||||
useRef,
|
||||
useState
|
||||
} from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../App';
|
||||
import RemoveRowButton from '../components/buttons/RemoveRowButton';
|
||||
@@ -54,8 +62,14 @@ import {
|
||||
type TableFieldRowProps
|
||||
} from '../components/forms/fields/TableField';
|
||||
import { Thumbnail } from '../components/images/Thumbnail';
|
||||
import { StatusRenderer } from '../components/render/StatusRenderer';
|
||||
import { RenderStockLocation } from '../components/render/Stock';
|
||||
import {
|
||||
StatusRenderer,
|
||||
getStatusCodeOptions
|
||||
} from '../components/render/StatusRenderer';
|
||||
import {
|
||||
RenderStockItem,
|
||||
RenderStockLocation
|
||||
} from '../components/render/Stock';
|
||||
import { StatusFilterOptions } from '../components/tables/Filter';
|
||||
import { InvenTreeIcon } from '../functions/icons';
|
||||
import {
|
||||
@@ -438,6 +452,497 @@ export function useStockItemSerializeFields({
|
||||
}, [serialGenerator.result]);
|
||||
}
|
||||
|
||||
function DisassemblyLineRow({
|
||||
props,
|
||||
record,
|
||||
installedItems,
|
||||
serialized,
|
||||
statuses
|
||||
}: Readonly<{
|
||||
props: TableFieldRowProps;
|
||||
record: any;
|
||||
installedItems: any[];
|
||||
serialized: boolean;
|
||||
statuses: any;
|
||||
}>) {
|
||||
// Number of assemblies being disassembled (top-level form field)
|
||||
const assemblies = useWatch({ name: 'quantity' });
|
||||
|
||||
// Once the user manually edits the row quantity, stop auto-scaling it
|
||||
const [edited, setEdited] = useState<boolean>(false);
|
||||
|
||||
const [installedOpen, installedHandlers] = useDisclosure(false);
|
||||
|
||||
const [locationOpen, locationHandlers] = useDisclosure(false, {
|
||||
onClose: () => props.changeFn(props.rowId, 'location', undefined)
|
||||
});
|
||||
|
||||
const [statusOpen, statusHandlers] = useDisclosure(false, {
|
||||
onClose: () => props.changeFn(props.rowId, 'status', undefined)
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (edited) {
|
||||
return;
|
||||
}
|
||||
|
||||
const n = Number(assemblies);
|
||||
const unit = Number(record.quantity);
|
||||
|
||||
if (Number.isFinite(n) && Number.isFinite(unit)) {
|
||||
const expected = unit * n;
|
||||
|
||||
if (props.item.quantity !== expected) {
|
||||
props.changeFn(props.rowId, 'quantity', expected);
|
||||
}
|
||||
}
|
||||
}, [assemblies, edited]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Group gap='xs' justify='left' wrap='nowrap'>
|
||||
<Thumbnail
|
||||
size={32}
|
||||
src={record.sub_part_detail?.thumbnail}
|
||||
align='center'
|
||||
/>
|
||||
<Stack gap={2}>
|
||||
<div>{record.sub_part_detail?.name}</div>
|
||||
{installedItems.length > 0 && (
|
||||
<UnstyledButton
|
||||
onClick={() => installedHandlers.toggle()}
|
||||
aria-label={`toggle-installed-items-${props.rowId}`}
|
||||
>
|
||||
<Group gap={4} wrap='nowrap'>
|
||||
{installedOpen ? (
|
||||
<IconChevronUp size={14} />
|
||||
) : (
|
||||
<IconChevronDown size={14} />
|
||||
)}
|
||||
<Text size='xs' c='blue'>
|
||||
{t`Installed Items`}: {installedItems.length}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>{record.quantity}</Table.Td>
|
||||
<Table.Td style={{ whiteSpace: 'nowrap' }}>
|
||||
{serialized ? (
|
||||
// Quantity is fixed when disassembling a serialized stock item
|
||||
<Text size='sm' aria-label='text-field-quantity'>
|
||||
{formatDecimal(props.item.quantity)}
|
||||
</Text>
|
||||
) : (
|
||||
<TableFieldQuantityInput
|
||||
min={0}
|
||||
value={props.item.quantity ?? ''}
|
||||
onChange={(value) => {
|
||||
setEdited(true);
|
||||
props.changeFn(props.rowId, 'quantity', value);
|
||||
}}
|
||||
error={props.rowErrors?.quantity?.message}
|
||||
/>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td style={{ whiteSpace: 'nowrap' }}>
|
||||
<NumberInput
|
||||
radius='sm'
|
||||
aria-label='number-field-purchase_price'
|
||||
placeholder={t`Automatic`}
|
||||
min={0}
|
||||
decimalScale={6}
|
||||
value={props.item.purchase_price ?? ''}
|
||||
onChange={(value) => {
|
||||
props.changeFn(
|
||||
props.rowId,
|
||||
'purchase_price',
|
||||
value === '' ? null : value
|
||||
);
|
||||
}}
|
||||
error={props.rowErrors?.purchase_price?.message}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td style={{ width: '1%', whiteSpace: 'nowrap' }}>
|
||||
<Flex gap='1px'>
|
||||
<ActionButton
|
||||
size='sm'
|
||||
onClick={() => locationHandlers.toggle()}
|
||||
icon={<InvenTreeIcon icon='location' />}
|
||||
tooltip={t`Set Location`}
|
||||
tooltipAlignment='top'
|
||||
variant={locationOpen ? 'outline' : 'transparent'}
|
||||
/>
|
||||
<ActionButton
|
||||
size='sm'
|
||||
onClick={() => statusHandlers.toggle()}
|
||||
icon={<InvenTreeIcon icon='status' />}
|
||||
tooltip={t`Change Status`}
|
||||
tooltipAlignment='top'
|
||||
variant={statusOpen ? 'outline' : 'transparent'}
|
||||
/>
|
||||
</Flex>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<RemoveRowButton
|
||||
onClick={() => props.removeFn(props.rowId)}
|
||||
disabled={installedItems.length > 0}
|
||||
tooltip={
|
||||
installedItems.length > 0
|
||||
? t`This row cannot be removed as it has installed items`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{installedItems.length > 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6} style={{ padding: 0, borderBottom: 'none' }}>
|
||||
<Collapse expanded={installedOpen}>
|
||||
<Paper p='xs' pl='xl'>
|
||||
<Stack gap='xs'>
|
||||
<Text size='xs' c='dimmed'>
|
||||
{t`The following installed items will be uninstalled during disassembly`}
|
||||
</Text>
|
||||
{installedItems.map((item: any) => (
|
||||
<RenderStockItem key={item.pk} instance={item} />
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Collapse>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
<TableFieldExtraRow
|
||||
visible={locationOpen}
|
||||
fieldName='location'
|
||||
onValueChange={(value) =>
|
||||
props.changeFn(props.rowId, 'location', value)
|
||||
}
|
||||
fieldDefinition={{
|
||||
field_type: 'related field',
|
||||
model: ModelType.stocklocation,
|
||||
api_url: apiUrl(ApiEndpoints.stock_location_list),
|
||||
filters: {
|
||||
structural: false
|
||||
},
|
||||
value: props.item.location,
|
||||
label: t`Location`,
|
||||
description: t`Custom location for the component items`,
|
||||
icon: <InvenTreeIcon icon='location' />
|
||||
}}
|
||||
error={props.rowErrors?.location?.message}
|
||||
/>
|
||||
<TableFieldExtraRow
|
||||
visible={statusOpen}
|
||||
fieldName='status'
|
||||
defaultValue={10}
|
||||
onValueChange={(value) => props.changeFn(props.rowId, 'status', value)}
|
||||
fieldDefinition={{
|
||||
field_type: 'choice',
|
||||
api_url: apiUrl(ApiEndpoints.stock_status),
|
||||
choices: statuses,
|
||||
label: t`Status`,
|
||||
description: t`Status for the component items`
|
||||
}}
|
||||
error={props.rowErrors?.status?.message}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a summary of the stock item which is about to be disassembled.
|
||||
*/
|
||||
function DisassemblyItemInfo({
|
||||
stockItem
|
||||
}: Readonly<{
|
||||
stockItem: any;
|
||||
}>) {
|
||||
const serialized: boolean =
|
||||
!!stockItem.serial && Number(stockItem.quantity) == 1;
|
||||
|
||||
const details: { label: string; value: ReactNode }[] = useMemo(() => {
|
||||
const rows = [
|
||||
{
|
||||
label: t`Location`,
|
||||
value: stockItem.location_detail?.pathstring ?? t`No location set`
|
||||
}
|
||||
];
|
||||
|
||||
if (serialized) {
|
||||
rows.push({
|
||||
label: t`Serial Number`,
|
||||
value: stockItem.serial
|
||||
});
|
||||
} else {
|
||||
rows.push({
|
||||
label: t`Quantity`,
|
||||
value: formatDecimal(stockItem.quantity)
|
||||
});
|
||||
}
|
||||
|
||||
if (stockItem.batch) {
|
||||
rows.push({
|
||||
label: t`Batch Code`,
|
||||
value: stockItem.batch
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}, [stockItem, serialized]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm'>
|
||||
<Group gap='md' wrap='nowrap' align='center'>
|
||||
<Thumbnail
|
||||
size={56}
|
||||
src={stockItem.part_detail?.thumbnail}
|
||||
align='center'
|
||||
/>
|
||||
<Group grow wrap='nowrap'>
|
||||
<Stack gap={2}>
|
||||
<Text fw={700}>
|
||||
{stockItem.part_detail?.full_name ?? stockItem.part_detail?.name}
|
||||
</Text>
|
||||
{stockItem.part_detail?.description && (
|
||||
<Text size='sm' c='dimmed'>
|
||||
{stockItem.part_detail.description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Table withRowBorders={false} verticalSpacing={2}>
|
||||
<Table.Tbody>
|
||||
{details.map((row) => (
|
||||
<Table.Tr key={row.label}>
|
||||
<Table.Td style={{ width: '1%', whiteSpace: 'nowrap' }}>
|
||||
<Text size='sm' c='dimmed'>
|
||||
{row.label}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size='sm'>{row.value}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display any installed stock items which could not be matched
|
||||
* against a BOM line item for the disassembly operation.
|
||||
*/
|
||||
function DisassemblyLeftoverItems({
|
||||
items
|
||||
}: Readonly<{
|
||||
items: any[];
|
||||
}>) {
|
||||
if (items.length == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert
|
||||
color='yellow'
|
||||
icon={<IconPackage />}
|
||||
title={t`Additional Installed Items`}
|
||||
>
|
||||
<Stack gap='xs'>
|
||||
<Text size='sm'>
|
||||
{t`The following items are installed within this stock item, but do not match a listed component. They will be uninstalled during disassembly.`}
|
||||
</Text>
|
||||
{items.map((item: any) => (
|
||||
<RenderStockItem key={item.pk} instance={item} />
|
||||
))}
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Form for disassembling a stock item into its component parts,
|
||||
* based on the Bill of Materials for the associated part.
|
||||
*/
|
||||
export function useDisassembleStockItem({
|
||||
stockItem,
|
||||
refresh
|
||||
}: {
|
||||
stockItem: any;
|
||||
refresh: () => void;
|
||||
}) {
|
||||
// Fetch BOM lines for the part associated with the stock item
|
||||
const bomQuery = useQuery({
|
||||
queryKey: ['bom-items-disassemble', stockItem.pk, stockItem.part],
|
||||
enabled: !!stockItem.part && (stockItem.part_detail?.assembly ?? false),
|
||||
queryFn: async () =>
|
||||
api
|
||||
.get(apiUrl(ApiEndpoints.bom_list), {
|
||||
params: {
|
||||
part: stockItem.part,
|
||||
sub_part_detail: true,
|
||||
substitutes: true
|
||||
}
|
||||
})
|
||||
.then((response) => response.data ?? [])
|
||||
});
|
||||
|
||||
// Fetch any stock items which are installed within this stock item
|
||||
const installedQuery = useQuery({
|
||||
queryKey: ['installed-items-disassemble', stockItem.pk],
|
||||
enabled: !!stockItem.pk && (stockItem.part_detail?.assembly ?? false),
|
||||
queryFn: async () =>
|
||||
api
|
||||
.get(apiUrl(ApiEndpoints.stock_item_list), {
|
||||
params: {
|
||||
belongs_to: stockItem.pk,
|
||||
part_detail: true
|
||||
}
|
||||
})
|
||||
.then((response) => response.data ?? [])
|
||||
});
|
||||
|
||||
const bomItems: any[] = useMemo(() => {
|
||||
// Consumable BOM lines are excluded from disassembly,
|
||||
// as are any lines which point to a virtual part
|
||||
return (bomQuery.data ?? []).filter(
|
||||
(elem: any) =>
|
||||
!elem.consumable &&
|
||||
!elem.sub_part_detail?.consumable &&
|
||||
!elem.sub_part_detail?.virtual
|
||||
);
|
||||
}, [bomQuery.data]);
|
||||
|
||||
const records = useMemo(
|
||||
() => Object.fromEntries(bomItems.map((elem: any) => [elem.pk, elem])),
|
||||
[bomItems]
|
||||
);
|
||||
|
||||
// Map installed stock items against the available BOM lines.
|
||||
// Note: This mirrors the matching performed by the server during disassembly,
|
||||
// matching against the referenced sub_part or any designated substitute parts.
|
||||
// Variants of the sub_part cannot be resolved client-side, so any such items
|
||||
// are reported as "leftover" items instead.
|
||||
const { installedMap, leftoverItems } = useMemo(() => {
|
||||
const map: Record<number, any[]> = {};
|
||||
const leftover: any[] = [];
|
||||
|
||||
for (const item of installedQuery.data ?? []) {
|
||||
const bomItem = bomItems.find(
|
||||
(elem: any) =>
|
||||
elem.sub_part === item.part ||
|
||||
elem.substitutes?.some((sub: any) => sub.part === item.part)
|
||||
);
|
||||
|
||||
if (bomItem) {
|
||||
map[bomItem.pk] = [...(map[bomItem.pk] ?? []), item];
|
||||
} else {
|
||||
leftover.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return { installedMap: map, leftoverItems: leftover };
|
||||
}, [bomItems, installedQuery.data]);
|
||||
|
||||
// A serialized stock item must be disassembled in its entirety
|
||||
const serialized: boolean =
|
||||
!!stockItem.serial && Number(stockItem.quantity) == 1;
|
||||
|
||||
const stockStatusCodes = useMemo(
|
||||
() => getStatusCodeOptions(ModelType.stockitem),
|
||||
[]
|
||||
);
|
||||
|
||||
const fields: ApiFormFieldSet = useMemo(() => {
|
||||
return {
|
||||
quantity: {
|
||||
disabled: serialized
|
||||
},
|
||||
location: {
|
||||
filters: {
|
||||
structural: false
|
||||
}
|
||||
},
|
||||
items: {
|
||||
field_type: 'table',
|
||||
value: bomItems.map((elem: any) => {
|
||||
return {
|
||||
id: elem.pk,
|
||||
bom_item: elem.pk,
|
||||
quantity:
|
||||
(Number(elem.quantity) || 0) * (Number(stockItem.quantity) || 0),
|
||||
purchase_price: null,
|
||||
purchase_price_currency:
|
||||
stockItem.purchase_price_currency ?? undefined
|
||||
};
|
||||
}),
|
||||
modelRenderer: (row: TableFieldRowProps) => {
|
||||
const record = records[row.item.bom_item];
|
||||
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DisassemblyLineRow
|
||||
props={row}
|
||||
record={record}
|
||||
installedItems={installedMap[row.item.bom_item] ?? []}
|
||||
serialized={serialized}
|
||||
statuses={stockStatusCodes}
|
||||
key={row.rowId}
|
||||
/>
|
||||
);
|
||||
},
|
||||
headers: [
|
||||
{ title: t`Component`, style: { minWidth: '200px' } },
|
||||
{ title: t`Unit Quantity`, style: { width: '100px' } },
|
||||
{ title: t`Quantity`, style: { width: '200px' } },
|
||||
{ title: t`Unit Price`, style: { width: '200px' } },
|
||||
{ title: t`Actions` },
|
||||
{ title: '', style: { width: '50px' } }
|
||||
]
|
||||
},
|
||||
notes: {}
|
||||
};
|
||||
}, [
|
||||
bomItems,
|
||||
records,
|
||||
installedMap,
|
||||
stockItem,
|
||||
serialized,
|
||||
stockStatusCodes
|
||||
]);
|
||||
|
||||
return useCreateApiFormModal({
|
||||
url: ApiEndpoints.stock_disassemble,
|
||||
pk: stockItem.pk,
|
||||
title: t`Disassemble Stock Item`,
|
||||
fields: fields,
|
||||
preFormContent: <DisassemblyItemInfo stockItem={stockItem} />,
|
||||
postFormContent: <DisassemblyLeftoverItems items={leftoverItems} />,
|
||||
initialData: {
|
||||
quantity: stockItem.quantity,
|
||||
location: stockItem.location
|
||||
},
|
||||
size: '80%',
|
||||
successMessage: t`Stock item disassembled`,
|
||||
onFormSuccess: refresh,
|
||||
onOpen: () => {
|
||||
// Ensure the installed items data is up to date when the form is opened
|
||||
installedQuery.refetch();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function StockItemDefaultMove({
|
||||
stockItem,
|
||||
value
|
||||
|
||||
@@ -232,6 +232,8 @@ export default function BuildDetail() {
|
||||
part_detail: true,
|
||||
tags: true
|
||||
},
|
||||
hasPrimaryKey: true,
|
||||
defaultValue: {},
|
||||
refetchOnMount: true
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconArrowsSplit,
|
||||
IconBookmark,
|
||||
IconBoxPadding,
|
||||
IconChecklist,
|
||||
@@ -69,6 +70,7 @@ import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
|
||||
import { useApi } from '../../contexts/ApiContext';
|
||||
import { formatCurrency, formatDecimal } from '../../defaults/formatters';
|
||||
import {
|
||||
useDisassembleStockItem,
|
||||
useFindSerialNumberForm,
|
||||
useStockFields,
|
||||
useStockItemSerializeFields
|
||||
@@ -864,6 +866,11 @@ export default function StockDetail() {
|
||||
successMessage: t`Stock item serialized`
|
||||
});
|
||||
|
||||
const disassembleStockItem = useDisassembleStockItem({
|
||||
stockItem: stockitem,
|
||||
refresh: refreshInstance
|
||||
});
|
||||
|
||||
const orderPartsWizard = OrderPartsWizard({
|
||||
parts: stockitem.part_detail ? [stockitem.part_detail] : []
|
||||
});
|
||||
@@ -950,6 +957,18 @@ export default function StockDetail() {
|
||||
serializeStockItem.open();
|
||||
}
|
||||
},
|
||||
{
|
||||
name: t`Disassemble`,
|
||||
tooltip: t`Disassemble this stock item into its component parts`,
|
||||
hidden:
|
||||
!user.hasAddRole(UserRoles.stock) ||
|
||||
!stockitem.in_stock ||
|
||||
stockitem.part_detail?.assembly != true,
|
||||
icon: <IconArrowsSplit color='blue' />,
|
||||
onClick: () => {
|
||||
disassembleStockItem.open();
|
||||
}
|
||||
},
|
||||
{
|
||||
name: t`Order`,
|
||||
tooltip: t`Order Stock`,
|
||||
@@ -1118,6 +1137,7 @@ export default function StockDetail() {
|
||||
{convertStockItem.modal}
|
||||
{duplicateStockItem.modal}
|
||||
{serializeStockItem.modal}
|
||||
{disassembleStockItem.modal}
|
||||
{stockAdjustActions.modals.map((modal) => modal.modal)}
|
||||
{orderPartsWizard.wizard}
|
||||
</>
|
||||
|
||||
@@ -148,6 +148,7 @@ test('Build Order - Tags', async ({ browser }) => {
|
||||
|
||||
// Check for expected results
|
||||
await page.getByRole('cell', { name: 'BO0026' }).click();
|
||||
await page.getByText('Build Order: BO0026').first().waitFor();
|
||||
await page.getByText('100 x 002.01-PCBA | Widget').waitFor();
|
||||
|
||||
// Check for tags displayed on BuildOrder detail page
|
||||
@@ -279,6 +280,7 @@ test('Build Order - Build Outputs', async ({ browser }) => {
|
||||
await page.getByRole('cell', { name: 'BO0011' }).click();
|
||||
await loadTab(page, 'Incomplete Outputs');
|
||||
await page.getByRole('cell', { name: 'BX-123' }).waitFor();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Check the "printing" actions for the selected outputs
|
||||
await page.getByRole('checkbox', { name: 'Select all records' }).check();
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { expect, test } from '../baseFixtures.js';
|
||||
import { stevenuser } from '../defaults.js';
|
||||
import {
|
||||
activateCalendarView,
|
||||
clearTableFilters,
|
||||
clickButtonIfVisible,
|
||||
clickOnRowMenu,
|
||||
loadTab,
|
||||
navigate,
|
||||
openFilterDrawer,
|
||||
@@ -585,208 +583,68 @@ test('Stock - Location', async ({ browser }) => {
|
||||
await page.getByText('No match found for barcode data').waitFor();
|
||||
});
|
||||
|
||||
test('Transfer Orders - General', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
// Test disassembly of an assembled stock item
|
||||
test('Stock - Disassembly', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, { url: 'stock/item/1387/details' });
|
||||
|
||||
await page.getByRole('tab', { name: 'Stock' }).click();
|
||||
await page.waitForURL('**/stock/location/index/**');
|
||||
|
||||
await loadTab(page, 'Transfer Orders');
|
||||
|
||||
await clearTableFilters(page);
|
||||
|
||||
// We have now loaded the "Transfer Orders" table. Check for some expected texts
|
||||
await page.getByText('Complete').first().waitFor();
|
||||
await page.getByText('Issued').first().waitFor();
|
||||
await page.getByText('Cancelled').first().waitFor();
|
||||
|
||||
// Load a particular Transfer Order
|
||||
await page.getByRole('cell', { name: 'TO-0002' }).click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// This transfer order should be "issued"
|
||||
await page.getByText('Issued').first().waitFor();
|
||||
|
||||
// Edit the transfer order (via keyboard shortcut)
|
||||
await page.keyboard.press('Control+E');
|
||||
await page.getByLabel('text-field-reference', { exact: true }).waitFor();
|
||||
await page.getByLabel('related-field-project_code').waitFor();
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete Order' }).click();
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
// Check for other expected actions
|
||||
await page.getByRole('button', { name: 'action-menu-order-actions' }).click();
|
||||
await page.getByLabel('action-menu-order-actions-edit').waitFor();
|
||||
await page.getByLabel('action-menu-order-actions-duplicate').waitFor();
|
||||
await page.getByLabel('action-menu-order-actions-hold').waitFor();
|
||||
|
||||
// Click on some tabs
|
||||
await loadTab(page, 'Line Items');
|
||||
await loadTab(page, 'Allocated Stock');
|
||||
await loadTab(page, 'Parameters');
|
||||
await loadTab(page, 'Attachments');
|
||||
await loadTab(page, 'Notes');
|
||||
});
|
||||
|
||||
test('Transfer Order - Reference', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
// go to transfer orders
|
||||
await page.getByRole('tab', { name: 'Stock' }).click();
|
||||
await page.waitForURL('**/stock/location/index/**');
|
||||
await loadTab(page, 'Transfer Orders');
|
||||
|
||||
// click add button
|
||||
// Duplicate this stock item to avoid impacting other tests
|
||||
await page
|
||||
.getByRole('button', { name: 'action-button-add-transfer-' })
|
||||
.getByRole('button', { name: 'action-menu-stock-item-actions' })
|
||||
.click();
|
||||
|
||||
// Ensure a new reference is suggested
|
||||
await page
|
||||
.getByRole('menuitem', { name: 'action-menu-stock-item-actions-duplicate' })
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.waitForTimeout(100);
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(250);
|
||||
|
||||
// Grab the Transfer Order reference
|
||||
const reference: string = await page
|
||||
.getByRole('textbox', { name: 'text-field-reference' })
|
||||
.inputValue();
|
||||
expect(reference).toMatch(/TO-\d+/);
|
||||
|
||||
await page.getByRole('textbox', { name: 'text-field-description' }).click();
|
||||
// Disassemble this stock item
|
||||
await page
|
||||
.getByRole('textbox', { name: 'text-field-description' })
|
||||
.fill('creating from playwrigh!');
|
||||
|
||||
// create the transfer order
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Item Created').waitFor();
|
||||
|
||||
// go back to stock page
|
||||
await page.getByRole('link', { name: 'Stock', exact: true }).click();
|
||||
.getByRole('button', { name: 'action-menu-stock-operations' })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('button', { name: 'action-button-add-transfer-' })
|
||||
.getByRole('menuitem', { name: 'action-menu-stock-operations-disassemble' })
|
||||
.click();
|
||||
|
||||
const nextReference: string = await page
|
||||
.getByRole('textbox', { name: 'text-field-reference' })
|
||||
.inputValue();
|
||||
expect(nextReference).toMatch(/TO-\d+/);
|
||||
// Perform partial disassembly
|
||||
await page
|
||||
.getByRole('textbox', {
|
||||
name: 'number-field-quantity',
|
||||
description: 'Number of assemblies to disassemble'
|
||||
})
|
||||
.fill('6');
|
||||
|
||||
// Ensure that the reference has incremented
|
||||
const refNumber = Number(reference.replace('TO-', ''));
|
||||
const nextRefNumber = Number(nextReference.replace('TO-', ''));
|
||||
expect(nextRefNumber).toBe(refNumber + 1);
|
||||
});
|
||||
|
||||
test('Transfer Order - Calendar', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await navigate(page, 'stock/location/index/transfer-orders');
|
||||
await activateCalendarView(page);
|
||||
|
||||
// Export calendar data
|
||||
await page.getByLabel('calendar-export-data').click();
|
||||
await page.getByRole('button', { name: 'Export', exact: true }).click();
|
||||
await page.getByText('Process completed successfully').waitFor();
|
||||
|
||||
// Required because we downloaded a file
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('Transfer Order - Edit', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await navigate(page, 'stock/transfer-order/2/');
|
||||
|
||||
// Check for expected text items
|
||||
await page.getByText('Consume some paint').first().waitFor();
|
||||
await page.getByText('2026-04-20').waitFor(); // Created date
|
||||
await page.getByText('2026-04-23').waitFor(); // Issue date
|
||||
await page.getByText('PRJ-HEL').waitFor(); // Project Code
|
||||
|
||||
await page.keyboard.press('Control+E');
|
||||
|
||||
// Edit start date
|
||||
await page.getByLabel('date-field-start_date').fill('2026-04-28');
|
||||
|
||||
// Submit the form
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Expect error
|
||||
await page.getByText('Errors exist for one or more form fields').waitFor();
|
||||
await page.getByText('Target date must be after start date').waitFor();
|
||||
|
||||
// Cancel the form
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
});
|
||||
|
||||
test('Transfer Order - Allocate and Transfer', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await navigate(page, 'stock/transfer-order/6/');
|
||||
|
||||
// Duplicate this transfer order, to ensure a fresh run each time
|
||||
await page.getByLabel('action-menu-order-actions').click();
|
||||
await page.getByLabel('action-menu-order-actions-duplicate').click();
|
||||
|
||||
// Submit the duplicate request and ensure it completes
|
||||
await page.getByRole('button', { name: 'Submit' }).isEnabled();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Item Created').waitFor();
|
||||
|
||||
// Issue the order
|
||||
await page.getByRole('button', { name: 'Issue Order' }).click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Issued', { exact: true }).first().waitFor();
|
||||
|
||||
await loadTab(page, 'Line Items');
|
||||
|
||||
// Allocate line item 1
|
||||
const cell1 = await page.getByText('C_100pF_0402', { exact: true });
|
||||
await clickOnRowMenu(cell1);
|
||||
await page.getByRole('menuitem', { name: 'Allocate Stock' }).click();
|
||||
await page.getByText('C_100pF_0402Location:Offsite').waitFor();
|
||||
await page.waitForTimeout(200);
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Allocate line item 1
|
||||
const cell2 = await page.getByText('R_2.2K_0603_1%', { exact: true });
|
||||
await clickOnRowMenu(cell2);
|
||||
await page.getByRole('menuitem', { name: 'Allocate Stock' }).click();
|
||||
await page.getByText('R_2.2K_0603_1%Location:').waitFor();
|
||||
await page.waitForTimeout(200);
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Complete the order
|
||||
await page.getByRole('button', { name: 'Complete Order' }).click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Complete', { exact: true }).first().waitFor();
|
||||
|
||||
// Tab should have changed to Transferred Stock
|
||||
await loadTab(page, 'Transferred Stock');
|
||||
await page.getByText('C_100pF_0402').waitFor();
|
||||
await page.getByText('2.2K resistor in 0603 SMD').waitFor();
|
||||
});
|
||||
|
||||
test('Transfer Orders - Duplicate', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, {
|
||||
url: 'stock/transfer-order/1/detail'
|
||||
});
|
||||
|
||||
await page.getByLabel('action-menu-order-actions').click();
|
||||
await page.getByLabel('action-menu-order-actions-duplicate').click();
|
||||
|
||||
// Ensure a new reference is suggested
|
||||
await expect(
|
||||
page.getByLabel('text-field-reference', { exact: true })
|
||||
).not.toBeEmpty();
|
||||
|
||||
// Submit the duplicate request and ensure it completes
|
||||
await page.getByRole('button', { name: 'Submit' }).isEnabled();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByRole('tab', { name: 'Order Details' }).waitFor();
|
||||
await page.getByRole('tab', { name: 'Order Details' }).click();
|
||||
|
||||
await page.getByText('Pending').first().waitFor();
|
||||
// Set custom status for one item
|
||||
await page
|
||||
.getByRole('row', { name: 'Thumbnail XT90-M 10 60 action' })
|
||||
.getByLabel('action-button-change-status')
|
||||
.click();
|
||||
await page
|
||||
.getByRole('combobox', { name: 'choice-field-status' })
|
||||
.fill('damaged');
|
||||
await page.getByRole('option', { name: 'Damaged' }).click();
|
||||
|
||||
// Specify different location for one item
|
||||
await page
|
||||
.getByRole('row', { name: 'Thumbnail XT90-F 10 60' })
|
||||
.getByLabel('action-button-set-location')
|
||||
.click();
|
||||
await page
|
||||
.getByRole('cell', { name: 'Location Custom location for' })
|
||||
.getByPlaceholder('Select location')
|
||||
.fill('factory');
|
||||
await page.getByRole('listbox').getByText('Factory', { exact: true }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Quantity of this assembly has been reduced
|
||||
await page.getByText('Quantity: 4').first().waitFor();
|
||||
|
||||
await loadTab(page, 'Child Items');
|
||||
await page.getByText('1 - 2 / 2').waitFor();
|
||||
|
||||
await page.getByRole('cell', { name: 'Thumbnail XT90-F' }).waitFor();
|
||||
await page.getByRole('cell', { name: 'Thumbnail XT90-M' }).waitFor();
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { expect, test } from '../baseFixtures.js';
|
||||
import {
|
||||
activateCalendarView,
|
||||
clearTableFilters,
|
||||
clickOnRowMenu,
|
||||
loadTab,
|
||||
navigate
|
||||
} from '../helpers.js';
|
||||
import { doCachedLogin } from '../login.js';
|
||||
|
||||
test('Transfer Orders - General', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await page.getByRole('tab', { name: 'Stock' }).click();
|
||||
await page.waitForURL('**/stock/location/index/**');
|
||||
|
||||
await loadTab(page, 'Transfer Orders');
|
||||
|
||||
await clearTableFilters(page);
|
||||
|
||||
// We have now loaded the "Transfer Orders" table. Check for some expected texts
|
||||
await page.getByText('Complete').first().waitFor();
|
||||
await page.getByText('Issued').first().waitFor();
|
||||
await page.getByText('Cancelled').first().waitFor();
|
||||
|
||||
// Load a particular Transfer Order
|
||||
await page.getByRole('cell', { name: 'TO-0002' }).click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// This transfer order should be "issued"
|
||||
await page.getByText('Issued').first().waitFor();
|
||||
|
||||
// Edit the transfer order (via keyboard shortcut)
|
||||
await page.keyboard.press('Control+E');
|
||||
await page.getByLabel('text-field-reference', { exact: true }).waitFor();
|
||||
await page.getByLabel('related-field-project_code').waitFor();
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete Order' }).click();
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
// Check for other expected actions
|
||||
await page.getByRole('button', { name: 'action-menu-order-actions' }).click();
|
||||
await page.getByLabel('action-menu-order-actions-edit').waitFor();
|
||||
await page.getByLabel('action-menu-order-actions-duplicate').waitFor();
|
||||
await page.getByLabel('action-menu-order-actions-hold').waitFor();
|
||||
|
||||
// Click on some tabs
|
||||
await loadTab(page, 'Line Items');
|
||||
await loadTab(page, 'Allocated Stock');
|
||||
await loadTab(page, 'Parameters');
|
||||
await loadTab(page, 'Attachments');
|
||||
await loadTab(page, 'Notes');
|
||||
});
|
||||
|
||||
test('Transfer Order - Reference', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
// go to transfer orders
|
||||
await page.getByRole('tab', { name: 'Stock' }).click();
|
||||
await page.waitForURL('**/stock/location/index/**');
|
||||
await loadTab(page, 'Transfer Orders');
|
||||
|
||||
// click add button
|
||||
await page
|
||||
.getByRole('button', { name: 'action-button-add-transfer-' })
|
||||
.click();
|
||||
|
||||
// Ensure a new reference is suggested
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(250);
|
||||
|
||||
// Grab the Transfer Order reference
|
||||
const reference: string = await page
|
||||
.getByRole('textbox', { name: 'text-field-reference' })
|
||||
.inputValue();
|
||||
expect(reference).toMatch(/TO-\d+/);
|
||||
|
||||
await page.getByRole('textbox', { name: 'text-field-description' }).click();
|
||||
await page
|
||||
.getByRole('textbox', { name: 'text-field-description' })
|
||||
.fill('creating from playwrigh!');
|
||||
|
||||
// create the transfer order
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Item Created').waitFor();
|
||||
|
||||
// go back to stock page
|
||||
await page.getByRole('link', { name: 'Stock', exact: true }).click();
|
||||
await page
|
||||
.getByRole('button', { name: 'action-button-add-transfer-' })
|
||||
.click();
|
||||
|
||||
const nextReference: string = await page
|
||||
.getByRole('textbox', { name: 'text-field-reference' })
|
||||
.inputValue();
|
||||
expect(nextReference).toMatch(/TO-\d+/);
|
||||
|
||||
// Ensure that the reference has incremented
|
||||
const refNumber = Number(reference.replace('TO-', ''));
|
||||
const nextRefNumber = Number(nextReference.replace('TO-', ''));
|
||||
expect(nextRefNumber).toBe(refNumber + 1);
|
||||
});
|
||||
|
||||
test('Transfer Order - Calendar', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await navigate(page, 'stock/location/index/transfer-orders');
|
||||
await activateCalendarView(page);
|
||||
|
||||
// Export calendar data
|
||||
await page.getByLabel('calendar-export-data').click();
|
||||
await page.getByRole('button', { name: 'Export', exact: true }).click();
|
||||
await page.getByText('Process completed successfully').waitFor();
|
||||
|
||||
// Required because we downloaded a file
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('Transfer Order - Edit', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await navigate(page, 'stock/transfer-order/2/');
|
||||
|
||||
// Check for expected text items
|
||||
await page.getByText('Consume some paint').first().waitFor();
|
||||
await page.getByText('2026-04-20').waitFor(); // Created date
|
||||
await page.getByText('2026-04-23').waitFor(); // Issue date
|
||||
await page.getByText('PRJ-HEL').waitFor(); // Project Code
|
||||
|
||||
await page.keyboard.press('Control+E');
|
||||
|
||||
// Edit start date
|
||||
await page.getByLabel('date-field-start_date').fill('2026-04-28');
|
||||
|
||||
// Submit the form
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Expect error
|
||||
await page.getByText('Errors exist for one or more form fields').waitFor();
|
||||
await page.getByText('Target date must be after start date').waitFor();
|
||||
|
||||
// Cancel the form
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
});
|
||||
|
||||
test('Transfer Order - Allocate and Transfer', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await navigate(page, 'stock/transfer-order/6/');
|
||||
|
||||
// Duplicate this transfer order, to ensure a fresh run each time
|
||||
await page.getByLabel('action-menu-order-actions').click();
|
||||
await page.getByLabel('action-menu-order-actions-duplicate').click();
|
||||
|
||||
// Submit the duplicate request and ensure it completes
|
||||
await page.getByRole('button', { name: 'Submit' }).isEnabled();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Item Created').waitFor();
|
||||
|
||||
// Issue the order
|
||||
await page.getByRole('button', { name: 'Issue Order' }).click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Issued', { exact: true }).first().waitFor();
|
||||
|
||||
await loadTab(page, 'Line Items');
|
||||
|
||||
// Allocate line item 1
|
||||
const cell1 = await page.getByText('C_100pF_0402', { exact: true });
|
||||
await clickOnRowMenu(cell1);
|
||||
await page.getByRole('menuitem', { name: 'Allocate Stock' }).click();
|
||||
await page.getByText('C_100pF_0402Location:Offsite').waitFor();
|
||||
await page.waitForTimeout(200);
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Allocate line item 1
|
||||
const cell2 = await page.getByText('R_2.2K_0603_1%', { exact: true });
|
||||
await clickOnRowMenu(cell2);
|
||||
await page.getByRole('menuitem', { name: 'Allocate Stock' }).click();
|
||||
await page.getByText('R_2.2K_0603_1%Location:').waitFor();
|
||||
await page.waitForTimeout(200);
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Complete the order
|
||||
await page.getByRole('button', { name: 'Complete Order' }).click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Complete', { exact: true }).first().waitFor();
|
||||
|
||||
// Tab should have changed to Transferred Stock
|
||||
await loadTab(page, 'Transferred Stock');
|
||||
await page.getByText('C_100pF_0402').waitFor();
|
||||
await page.getByText('2.2K resistor in 0603 SMD').waitFor();
|
||||
});
|
||||
|
||||
test('Transfer Orders - Duplicate', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, {
|
||||
url: 'stock/transfer-order/1/detail'
|
||||
});
|
||||
|
||||
await page.getByLabel('action-menu-order-actions').click();
|
||||
await page.getByLabel('action-menu-order-actions-duplicate').click();
|
||||
|
||||
// Ensure a new reference is suggested
|
||||
await expect(
|
||||
page.getByLabel('text-field-reference', { exact: true })
|
||||
).not.toBeEmpty();
|
||||
|
||||
// Submit the duplicate request and ensure it completes
|
||||
await page.getByRole('button', { name: 'Submit' }).isEnabled();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByRole('tab', { name: 'Order Details' }).waitFor();
|
||||
await page.getByRole('tab', { name: 'Order Details' }).click();
|
||||
|
||||
await page.getByText('Pending').first().waitFor();
|
||||
});
|
||||
@@ -233,10 +233,10 @@ test('Forms - DateTime Field', async ({ browser }) => {
|
||||
hour: string,
|
||||
minute: string
|
||||
) => {
|
||||
const field = page.getByLabel(fieldLabel);
|
||||
const field = page.getByLabel(fieldLabel).first();
|
||||
await expect(field).toBeVisible();
|
||||
await field.click();
|
||||
await page.getByRole('button', { name: day }).click();
|
||||
await page.getByRole('button', { name: day }).first().click();
|
||||
|
||||
const spinbuttons = page.getByRole('spinbutton');
|
||||
await spinbuttons.nth(0).fill(hour);
|
||||
|
||||
Reference in New Issue
Block a user