mirror of
https://github.com/inventree/InvenTree.git
synced 2026-05-13 21:17:33 +00:00
Support physical units for BOM lines (#11631)
* Add new "raw_amount" field to BomItem model * Batch process data migration * Update migration * Calculate 'quantity' from 'raw_amount' field * Improve decimal formatting in migration * Allow raw_amount in serializer * Adjust frontend form * API validation and unit tests * Additional playwright tests * Update API version and CHANGELOG * Updated docs * Fix docstring * Better handling of empty values * Tweak unit tests * Tweak unit test * Fix unit test * Adjust form field text * Adjust migration file * Tweak playwright tests * Fix unit test * Adjust serializers / import-export / playwright * Fix migration * Fix validation * Loosen comparison --------- Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 484
|
||||
INVENTREE_API_VERSION = 485
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v485 -> 2026-05-10 : https://github.com/inventree/InvenTree/pull/11631
|
||||
- Adds "raw_amount" field to the BomItem API endpoint
|
||||
|
||||
v484 -> 2026-05-10 : https://github.com/inventree/InvenTree/pull/11910
|
||||
- Adds more docstrings to (scheduled) tasks
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ def from_engineering_notation(value):
|
||||
return value
|
||||
|
||||
|
||||
def convert_value(value, unit):
|
||||
def convert_value(value, unit=None):
|
||||
"""Attempt to convert a value to a specified unit.
|
||||
|
||||
Arguments:
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
part: 100
|
||||
sub_part: 1
|
||||
quantity: 10
|
||||
raw_amount: '10'
|
||||
allow_variants: True
|
||||
|
||||
# 40 x R_2K2_0805
|
||||
@@ -16,6 +17,7 @@
|
||||
part: 100
|
||||
sub_part: 3
|
||||
quantity: 40
|
||||
raw_amount: '40'
|
||||
|
||||
# 25 x C_22N_0805
|
||||
- model: part.bomitem
|
||||
@@ -24,6 +26,7 @@
|
||||
part: 100
|
||||
sub_part: 5
|
||||
quantity: 25
|
||||
raw_amount: '25'
|
||||
reference: ABCDE
|
||||
|
||||
# 3 x Orphan
|
||||
@@ -33,6 +36,7 @@
|
||||
part: 100
|
||||
sub_part: 50
|
||||
quantity: 3
|
||||
raw_amount: '3'
|
||||
reference: VWXYZ
|
||||
|
||||
- model: part.bomitem
|
||||
@@ -41,6 +45,7 @@
|
||||
part: 1
|
||||
sub_part: 5
|
||||
quantity: 3
|
||||
raw_amount: '3'
|
||||
reference: LMNOP
|
||||
|
||||
# Make "Assembly" from "Bob"
|
||||
@@ -50,6 +55,7 @@
|
||||
part: 101
|
||||
sub_part: 100
|
||||
quantity: 10
|
||||
raw_amount: '10'
|
||||
|
||||
- model: part.bomitemsubstitute
|
||||
pk: 1
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Generated by Django 5.2.12 on 2026-03-30 10:01
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
from InvenTree.helpers import normalize
|
||||
|
||||
|
||||
def set_default_raw_amount(apps, schema_editor):
|
||||
"""Initialize the 'raw_amount' field for existing BomItem records."""
|
||||
BomItem = apps.get_model("part", "BomItem")
|
||||
|
||||
to_update = []
|
||||
updated_count = 0
|
||||
|
||||
# Run BulkUpdate in batches to avoid memory issues with large datasets
|
||||
BATCH_SIZE = 100
|
||||
|
||||
N_BOM_ITEMS = BomItem.objects.count()
|
||||
|
||||
def add_bom_item(item):
|
||||
nonlocal to_update
|
||||
nonlocal updated_count
|
||||
|
||||
to_update.append(item)
|
||||
|
||||
if len(to_update) >= BATCH_SIZE:
|
||||
BomItem.objects.bulk_update(to_update, ["raw_amount"])
|
||||
updated_count += len(to_update)
|
||||
to_update = []
|
||||
|
||||
for item in BomItem.objects.all():
|
||||
item.raw_amount = str(normalize(Decimal(item.quantity)))
|
||||
add_bom_item(item)
|
||||
|
||||
# Handle any remaining items that were not updated in the loop
|
||||
if len(to_update) > 0:
|
||||
BomItem.objects.bulk_update(to_update, ["raw_amount"])
|
||||
updated_count += len(to_update)
|
||||
|
||||
assert updated_count == N_BOM_ITEMS, f"Expected to update {N_BOM_ITEMS} BomItem records, but updated {updated_count} records instead."
|
||||
|
||||
assert BomItem.objects.filter(raw_amount="").count() == 0, "There are BomItem records with an empty 'raw_amount' field after migration."
|
||||
|
||||
if updated_count > 0:
|
||||
print(f"Initialized 'raw_amount' field for {updated_count} BomItem records.")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
"""Run a set of data and schema migrations to add a new 'raw_amount' field to the BomItem model.
|
||||
|
||||
1. Add a new 'raw_amount' field to the BomItem model, which is a CharField that can store a raw amount of sub-part consumed to produce one part. This field can be used to store fractional amounts or amounts with associated units (e.g. '10 hours', '5 kg', etc.).
|
||||
2. Run a data migration to initialize the 'raw_amount' field for existing BomItem records, by copying the value from the existing 'quantity' field and converting it to a string.
|
||||
3. Mark the 'raw_amount' field as blank=False, now that the quantity values have been copied across
|
||||
"""
|
||||
|
||||
dependencies = [
|
||||
("part", "0148_auto_20260427_2233"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="bomitem",
|
||||
name="raw_amount",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
null=False,
|
||||
help_text="Amount of sub-part consumed to produce one part",
|
||||
max_length=25,
|
||||
verbose_name="Amount",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(set_default_raw_amount, reverse_code=migrations.RunPython.noop),
|
||||
migrations.AlterField(
|
||||
model_name="bomitem",
|
||||
name="raw_amount",
|
||||
field=models.CharField(
|
||||
blank=False,
|
||||
null=False,
|
||||
help_text="Amount of sub-part consumed to produce one part",
|
||||
max_length=25,
|
||||
verbose_name="Amount",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -3782,7 +3782,8 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
|
||||
Attributes:
|
||||
part: Link to the parent part (the part that will be produced)
|
||||
sub_part: Link to the child part (the part that will be consumed)
|
||||
quantity: Number of 'sub_parts' consumed to produce one 'part'
|
||||
raw_amount: Raw amount of 'sub_part' consumed to produce one 'part' (can be fractional, or use an associated unit)
|
||||
quantity: Numerical quantity of 'sub_parts' consumed to produce one 'part'
|
||||
optional: Boolean field describing if this BomItem is optional
|
||||
consumable: Boolean field describing if this BomItem is considered a 'consumable'
|
||||
reference: BOM reference field (e.g. part designators)
|
||||
@@ -3884,6 +3885,57 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
|
||||
"""
|
||||
return Q(part__in=self.get_valid_parts_for_allocation())
|
||||
|
||||
def set_quantity(self, quantity: Decimal | str | float):
|
||||
"""Update the 'quantity' for this BomItem."""
|
||||
self.raw_amount = quantity
|
||||
self.recalculate_quantity()
|
||||
|
||||
def recalculate_quantity(self):
|
||||
"""Recalculate the 'quantity' field based on the 'raw_amount' field."""
|
||||
if self.raw_amount is None or self.raw_amount == '':
|
||||
self.raw_amount = self.quantity
|
||||
|
||||
# Convert from the "raw amount" to a numerical quantity, using the associated unit (if specified)
|
||||
try:
|
||||
quantity = InvenTree.conversion.convert_physical_value(
|
||||
self.raw_amount, self.sub_part.units, strip_units=False
|
||||
)
|
||||
|
||||
if not self.sub_part.units and not InvenTree.conversion.is_dimensionless(
|
||||
quantity
|
||||
):
|
||||
raise ValidationError({
|
||||
'raw_amount': _('Invalid quantity - no units specified for part')
|
||||
})
|
||||
|
||||
allow_zero_qty = get_global_setting('PART_BOM_ALLOW_ZERO_QUANTITY', False)
|
||||
|
||||
if allow_zero_qty:
|
||||
if float(quantity.magnitude) < 0:
|
||||
raise ValidationError({
|
||||
'raw_amount': _(
|
||||
'Quantity must be greater than or equal to zero'
|
||||
)
|
||||
})
|
||||
|
||||
else:
|
||||
if float(quantity.magnitude) <= 0:
|
||||
raise ValidationError({
|
||||
'raw_amount': _('Quantity must be greater than zero')
|
||||
})
|
||||
|
||||
self.quantity = Decimal(quantity.magnitude)
|
||||
|
||||
except ValidationError as e:
|
||||
raise ValidationError({'raw_amount': e.messages})
|
||||
|
||||
# Ensure that the raw_amount is converted to a Decimal value
|
||||
try:
|
||||
self.quantity = Decimal(self.quantity)
|
||||
except InvalidOperation:
|
||||
msg = _('Invalid quantity provided')
|
||||
raise ValidationError({'quantity': msg, 'raw_amount': msg})
|
||||
|
||||
def delete(self):
|
||||
"""Check if this item can be deleted."""
|
||||
import part.tasks as part_tasks
|
||||
@@ -3990,7 +4042,15 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
|
||||
limit_choices_to={'component': True},
|
||||
)
|
||||
|
||||
# Quantity required
|
||||
raw_amount = models.CharField(
|
||||
max_length=25,
|
||||
verbose_name=_('Amount'),
|
||||
help_text=_('Amount of sub-part consumed to produce one part'),
|
||||
blank=False,
|
||||
null=False,
|
||||
)
|
||||
|
||||
# Native quantity required
|
||||
quantity = models.DecimalField(
|
||||
default=1.0,
|
||||
max_digits=15,
|
||||
@@ -4176,10 +4236,8 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
|
||||
"""
|
||||
super().clean()
|
||||
|
||||
try:
|
||||
self.quantity = Decimal(self.quantity)
|
||||
except InvalidOperation:
|
||||
raise ValidationError({'quantity': _('Must be a valid number')})
|
||||
# Recalculate the 'quantity' field based on the 'raw_amount' field
|
||||
self.recalculate_quantity()
|
||||
|
||||
try:
|
||||
# Check for circular BOM references
|
||||
|
||||
@@ -22,9 +22,9 @@ from sql_util.utils import SubqueryCount
|
||||
|
||||
import common.currency
|
||||
import common.filters
|
||||
import common.models
|
||||
import common.serializers
|
||||
import company.models
|
||||
import InvenTree.conversion
|
||||
import InvenTree.helpers
|
||||
import InvenTree.serializers
|
||||
import part.filters as part_filters
|
||||
@@ -1642,7 +1642,7 @@ class BomItemSerializer(
|
||||
):
|
||||
"""Serializer for BomItem object."""
|
||||
|
||||
import_exclude_fields = ['validated', 'substitutes']
|
||||
import_exclude_fields = ['quantity', 'validated', 'substitutes']
|
||||
|
||||
export_exclude_fields = ['substitutes']
|
||||
|
||||
@@ -1660,6 +1660,7 @@ class BomItemSerializer(
|
||||
'part',
|
||||
'sub_part',
|
||||
'reference',
|
||||
'raw_amount',
|
||||
'quantity',
|
||||
'allow_variants',
|
||||
'inherited',
|
||||
@@ -1694,7 +1695,13 @@ class BomItemSerializer(
|
||||
'category_detail',
|
||||
]
|
||||
|
||||
quantity = InvenTree.serializers.InvenTreeDecimalField(required=True)
|
||||
raw_amount = serializers.CharField(
|
||||
label=_('Amount'),
|
||||
help_text=_('Amount required for this item (can include units)'),
|
||||
required=False,
|
||||
)
|
||||
|
||||
quantity = InvenTree.serializers.InvenTreeDecimalField(required=False)
|
||||
|
||||
setup_quantity = InvenTree.serializers.InvenTreeDecimalField(required=False)
|
||||
|
||||
@@ -1704,25 +1711,6 @@ class BomItemSerializer(
|
||||
required=False, allow_null=True
|
||||
)
|
||||
|
||||
def validate_quantity(self, quantity):
|
||||
"""Perform validation for the BomItem quantity field."""
|
||||
allow_zero_qty = common.models.InvenTreeSetting.get_setting(
|
||||
'PART_BOM_ALLOW_ZERO_QUANTITY', False
|
||||
)
|
||||
|
||||
if allow_zero_qty:
|
||||
if quantity < 0:
|
||||
raise serializers.ValidationError(
|
||||
_('Quantity must be greater than or equal to zero')
|
||||
)
|
||||
else:
|
||||
if quantity <= 0:
|
||||
raise serializers.ValidationError(
|
||||
_('Quantity must be greater than zero')
|
||||
)
|
||||
|
||||
return quantity
|
||||
|
||||
part = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Part.objects.filter(assembly=True),
|
||||
label=_('Assembly'),
|
||||
@@ -1864,6 +1852,27 @@ class BomItemSerializer(
|
||||
|
||||
external_stock = serializers.FloatField(read_only=True, allow_null=True)
|
||||
|
||||
def validate(self, data):
|
||||
"""Validate the supplied data.
|
||||
|
||||
Here, for legacy support, we intercept the 'quantity' field
|
||||
(if the 'raw_amount' field is not provided)
|
||||
"""
|
||||
qty = data.pop('quantity', None)
|
||||
|
||||
if 'raw_amount' not in data and qty is not None:
|
||||
data['raw_amount'] = qty
|
||||
|
||||
# Check the raw_amount field is valid (this will raise a ValidationError if not)
|
||||
if raw_amount := data.get('raw_amount', None):
|
||||
try:
|
||||
# Check that the value is acceptable to the unit registry
|
||||
InvenTree.conversion.convert_value(raw_amount)
|
||||
except Exception:
|
||||
raise ValidationError({'raw_amount': _('Invalid quantity format')})
|
||||
|
||||
return super().validate(data)
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""Annotate the BomItem queryset with extra information.
|
||||
|
||||
@@ -1005,7 +1005,9 @@ class PartAPITest(PartAPITestBase):
|
||||
sub_part.refresh_from_db()
|
||||
|
||||
# Link the sub part to the assembly via a BOM
|
||||
bom_item = BomItem.objects.create(part=assembly, sub_part=sub_part, quantity=10)
|
||||
bom_item = BomItem.objects.create(
|
||||
part=assembly, sub_part=sub_part, raw_amount='10', quantity=10
|
||||
)
|
||||
|
||||
filters = {'active': True, 'assembly': True, 'bom_valid': True}
|
||||
|
||||
@@ -1023,14 +1025,14 @@ class PartAPITest(PartAPITestBase):
|
||||
self.assertEqual(response.data[0]['pk'], assembly.pk)
|
||||
|
||||
# Adjust the 'quantity' of the BOM item to make it invalid
|
||||
bom_item.quantity = 15
|
||||
bom_item.set_quantity(15)
|
||||
bom_item.save()
|
||||
|
||||
response = self.get(url, filters)
|
||||
self.assertEqual(len(response.data), 0)
|
||||
|
||||
# Adjust it back again - should be valid again
|
||||
bom_item.quantity = 10
|
||||
bom_item.set_quantity(10)
|
||||
bom_item.save()
|
||||
|
||||
response = self.get(url, filters)
|
||||
@@ -1054,7 +1056,7 @@ class PartAPITest(PartAPITestBase):
|
||||
self.assertIsNotNone(data['bom_checked_date'])
|
||||
|
||||
# Now, let's try to validate and invalidate the assembly BOM via the API
|
||||
bom_item.quantity = 99
|
||||
bom_item.raw_amount = ' 99'
|
||||
bom_item.save()
|
||||
|
||||
data = self.get(bom_url, expected_code=200).data
|
||||
@@ -2796,6 +2798,7 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
'rounding_multiple',
|
||||
'pk',
|
||||
'part',
|
||||
'raw_amount',
|
||||
'quantity',
|
||||
'reference',
|
||||
'sub_part',
|
||||
@@ -2821,6 +2824,7 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
|
||||
# Increase the quantity
|
||||
data = response.data
|
||||
del data['raw_amount']
|
||||
data['quantity'] = 57
|
||||
data['note'] = 'Added a note'
|
||||
|
||||
@@ -2829,6 +2833,14 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
self.assertEqual(int(float(response.data['quantity'])), 57)
|
||||
self.assertEqual(response.data['note'], 'Added a note')
|
||||
|
||||
# Provide a conflicting "raw_amount" and "quantity" field
|
||||
data['raw_amount'] = ' 123.45 '
|
||||
data['quantity'] = 99.99
|
||||
response = self.patch(url, data, expected_code=200)
|
||||
|
||||
self.assertEqual(response.data['raw_amount'], '123.45')
|
||||
self.assertAlmostEqual(response.data['quantity'], 123.45, places=2)
|
||||
|
||||
def test_output_options(self):
|
||||
"""Test that various output options work as expected."""
|
||||
self.run_output_test(
|
||||
@@ -2846,14 +2858,57 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
"""Test that we can create a new BomItem via the API."""
|
||||
url = reverse('api-bom-list')
|
||||
|
||||
# Test with legacy format (only the 'quantity' field is supplied)
|
||||
data = {'part': 100, 'sub_part': 4, 'quantity': 777}
|
||||
response = self.post(url, data, expected_code=201)
|
||||
self.assertEqual(response.data['raw_amount'], '777')
|
||||
self.assertEqual(response.data['quantity'], 777)
|
||||
|
||||
self.post(url, data, expected_code=201)
|
||||
# Test with the 'modern' format (accepts a raw_amount field)
|
||||
data = {'part': 100, 'sub_part': 4, 'raw_amount': '123.45'}
|
||||
response = self.post(url, data, expected_code=201)
|
||||
self.assertEqual(response.data['raw_amount'], '123.45')
|
||||
self.assertEqual(response.data['quantity'], 123.45)
|
||||
|
||||
# First, let's assign some units to the sub_part
|
||||
sub_part = Part.objects.get(pk=4)
|
||||
sub_part.units = 'metres'
|
||||
sub_part.save()
|
||||
|
||||
# Test with a bunch of invalid 'raw_amount' values
|
||||
for value in [
|
||||
'3 ampere',
|
||||
'17 degrees',
|
||||
'1 kg',
|
||||
'-4',
|
||||
'yak',
|
||||
'*****',
|
||||
'$$$$$',
|
||||
'',
|
||||
]:
|
||||
data = {'part': 100, 'sub_part': 4, 'raw_amount': value}
|
||||
self.post(url, data, expected_code=400)
|
||||
|
||||
# Test with a bunch of valid 'raw_amount' values
|
||||
test_values = [
|
||||
(5, 5),
|
||||
('3.14cm', 0.0314),
|
||||
('10 metres ', 10),
|
||||
('2 inches', 0.0508),
|
||||
('1/7', 0.142857),
|
||||
('14 ', 14),
|
||||
]
|
||||
|
||||
for raw_amount, quantity in test_values:
|
||||
data = {'part': 100, 'sub_part': 4, 'raw_amount': raw_amount}
|
||||
response = self.post(url, data, expected_code=201)
|
||||
self.assertEqual(response.data['raw_amount'], str(raw_amount).strip())
|
||||
self.assertAlmostEqual(response.data['quantity'], quantity, places=4)
|
||||
|
||||
# Now try to create a BomItem which references itself
|
||||
data['part'] = 100
|
||||
data['sub_part'] = 100
|
||||
self.post(url, data, expected_code=400)
|
||||
data = {'part': 100, 'sub_part': 100, 'quantity': 1}
|
||||
response = self.post(url, data, expected_code=400)
|
||||
self.assertIn('(recursive)', str(response.data))
|
||||
|
||||
def test_variants(self):
|
||||
"""Tests for BomItem use with variants."""
|
||||
|
||||
@@ -438,7 +438,7 @@ class BomItemTest(TestCase):
|
||||
|
||||
# Editing the BOM item should also invalidate the bom_validated cache
|
||||
validate()
|
||||
bom_item.quantity = 2
|
||||
bom_item.set_quantity(2)
|
||||
bom_item.save()
|
||||
check(valid=False)
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ class SampleValidatorPluginTest(InvenTreeAPITestCase, InvenTreeTestCase):
|
||||
self.assembly.refresh_from_db()
|
||||
|
||||
self.bom_item = part.models.BomItem.objects.create(
|
||||
part=self.assembly, sub_part=self.part, quantity=1
|
||||
part=self.assembly, sub_part=self.part, raw_amount=1, quantity=1
|
||||
)
|
||||
|
||||
self.enable_plugin(False)
|
||||
@@ -75,14 +75,14 @@ class SampleValidatorPluginTest(InvenTreeAPITestCase, InvenTreeTestCase):
|
||||
|
||||
plg.set_setting('BOM_ITEM_INTEGER', True)
|
||||
|
||||
self.bom_item.quantity = 3.14159
|
||||
self.bom_item.raw_amount = 3.14159
|
||||
with self.assertRaises(ValidationError):
|
||||
self.bom_item.save()
|
||||
|
||||
# Now, disable the plugin setting
|
||||
plg.set_setting('BOM_ITEM_INTEGER', False)
|
||||
|
||||
self.bom_item.quantity = 3.14159
|
||||
self.bom_item.set_quantity(3.14159)
|
||||
self.bom_item.save()
|
||||
|
||||
# Test that we *cannot* set a part description to a shorter value
|
||||
|
||||
@@ -38,7 +38,10 @@ export function bomItemFields({
|
||||
},
|
||||
addCreateFields: newPartFields
|
||||
},
|
||||
quantity: {},
|
||||
raw_amount: {
|
||||
label: t`Quantity`,
|
||||
description: t`Required component quantity`
|
||||
},
|
||||
reference: {},
|
||||
setup_quantity: {},
|
||||
attrition: {},
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
Assembly,Component,Reference,Quantity,Allow Variants,Gets inherited,Optional,Consumable,Setup quantity,Attrition,Rounding multiple,Note,ID,Pricing min,Pricing max,Pricing min total,Pricing max total,Pricing updated,Component.Ipn,Component.Name,Component.Description,Validated,Available Stock,Available substitute stock,Available variant stock,External stock,On Order,In Production,Can Build
|
||||
Assembly,Component,Reference,Amount,Allow Variants,Gets inherited,Optional,Consumable,Setup quantity,Attrition,Rounding multiple,Note,ID,Pricing min,Pricing max,Pricing min total,Pricing max total,Pricing updated,Component.Ipn,Component.Name,Component.Description,Validated,Available Stock,Available substitute stock,Available variant stock,External stock,On Order,In Production,Can Build
|
||||
106,98,screws,5,FALSE,TRUE,FALSE,TRUE,0,0,0,,39,0.075,0.1,0.375,0.5,23/07/2025 9:12,,Wood Screw,Screw for fixing wood to other wood,TRUE,1604,0,0,0,0,0,320.8
|
||||
106,95,legs,4,FALSE,TRUE,FALSE,FALSE,0,0,0,,40,10.6,12.75,42.4,51,23/07/2025 9:12,,Leg,Leg for a chair or a table,TRUE,317,0,0,0,0,0,79.25
|
||||
109,92,paint,0.125,FALSE,FALSE,FALSE,FALSE,0,0,0,,43,1.403886,14.389836,0.175486,1.79873,23/07/2025 9:12,,Green Paint,Green Paint,TRUE,110.125,0,0,0,0,0,881
|
||||
109,92,paint,quart,FALSE,FALSE,FALSE,FALSE,0,0,0,,43,1.403886,14.389836,0.175486,1.79873,23/07/2025 9:12,,Green Paint,Green Paint,TRUE,110.125,0,0,0,0,0,881
|
||||
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { test } from '../baseFixtures';
|
||||
import {
|
||||
clearTableFilters,
|
||||
@@ -219,6 +220,53 @@ test('Parts - BOM', async ({ browser }) => {
|
||||
await page.getByRole('button', { name: 'Add Substitute' }).waitFor();
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
|
||||
// Let's try a BOM which has a "raw amount" which considers the units of the underlying part
|
||||
await navigate(page, 'part/109/bom');
|
||||
|
||||
await page.getByRole('button', { name: 'action-button-edit-bom' }).click();
|
||||
|
||||
const paintCell = await page.getByRole('cell', {
|
||||
name: 'Thumbnail Green Paint'
|
||||
});
|
||||
await clickOnRowMenu(paintCell);
|
||||
await page.getByRole('menuitem', { name: 'Edit', exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: 'text-field-raw_amount' })
|
||||
).toHaveValue(/quart/);
|
||||
|
||||
// Try to assign invalid units to this item, which should be rejected by validation
|
||||
await page
|
||||
.getByRole('textbox', { name: 'text-field-raw_amount' })
|
||||
.fill('2 cm');
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
await page.getByText('Errors exist for one or more').waitFor();
|
||||
await page.getByText('Could not convert 2 cm to litres').waitFor();
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
// Create a new BOM item with valid units
|
||||
await page.getByRole('button', { name: 'action-menu-add-bom-items' }).click();
|
||||
await page
|
||||
.getByRole('menuitem', { name: 'action-menu-add-bom-items-add' })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('combobox', { name: 'related-field-sub_part' })
|
||||
.fill('red wire');
|
||||
await page
|
||||
.getByRole('option', { name: 'Thumbnail Silicon Wire 12AWG' })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('textbox', { name: 'text-field-reference' })
|
||||
.fill('my-ref');
|
||||
await page
|
||||
.getByRole('textbox', { name: 'text-field-raw_amount' })
|
||||
.fill('3/4 inches');
|
||||
await page.getByRole('switch', { name: 'boolean-field-optional' }).click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Check for the value converted back to [m]
|
||||
await page.getByRole('cell', { name: '0.01905' }).first().waitFor();
|
||||
await page.getByRole('cell', { name: 'my-ref' }).first().waitFor();
|
||||
// Finish editing the BOM
|
||||
await page
|
||||
.getByRole('button', { name: 'action-button-finish-editing-' })
|
||||
@@ -240,13 +288,15 @@ test('Parts - BOM Validation', async ({ browser }) => {
|
||||
.waitFor();
|
||||
|
||||
// Edit line item, to ensure BOM is not valid
|
||||
const cell = await page.getByRole('cell', { name: 'Thumbnail Red Paint' });
|
||||
const cell = await page.getByRole('cell', { name: 'paint', exact: true });
|
||||
|
||||
// await cell.click({ button: 'right' });
|
||||
// await page.getByRole('button', { name: 'Edit', exact: true }).click();
|
||||
await clickOnRowMenu(cell);
|
||||
await page.getByRole('menuitem', { name: 'Edit', exact: true }).click();
|
||||
|
||||
const input = await page.getByRole('textbox', {
|
||||
name: 'number-field-quantity'
|
||||
name: 'text-field-raw_amount'
|
||||
});
|
||||
|
||||
const value = await input.inputValue();
|
||||
@@ -901,7 +951,7 @@ test('Parts - Parameter Filtering', async ({ browser }) => {
|
||||
test('Parts - Test Results', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, { url: '/part/74/test_results' });
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
await page.getByText(/1 - \d+ \/ 1\d\d/).waitFor();
|
||||
await page.getByText('Blue Paint Applied').waitFor();
|
||||
|
||||
@@ -138,7 +138,7 @@ test('Importing - BOM', async ({ browser }) => {
|
||||
.getByLabel('row-action-menu-')
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||
await page.getByRole('textbox', { name: 'number-field-quantity' }).fill('12');
|
||||
await page.getByRole('textbox', { name: 'text-field-raw_amount' }).fill('12');
|
||||
|
||||
await page.waitForTimeout(250);
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
@@ -401,7 +401,7 @@ test('Settings - Admin - Parameter', async ({ browser }) => {
|
||||
await loadTab(page, 'Parameters', true);
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1000);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Clean old template data if exists
|
||||
await page
|
||||
|
||||
Reference in New Issue
Block a user