2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-30 00:21:34 +00:00

BOM Enhancements (#10042)

* Add "round_up_multiple" field

* Adjust field def

* Add serializer field

* Update frontend

* Nullify empty numerical values

* Calculate round_up_multiple value

* Adjust table rendering

* Update API version

* Add unit test

* Additional unit test

* Change name of value

* Update BOM docs

* Add new fields

* Add data migration for new fields

* Bug fix for data migration

* Adjust API fields

* Bump API docs

* Update frontend

* Remove old 'overage' field

* Updated BOM docs

* Docs tweak

* Fix required quantity calculation

* additional unit tests

* Tweak BOM table

* Enhanced "can_build" serializer

* Refactor "can_build" calculations

* Code cleanup

* Serializer fix

* Enhanced rendering

* Updated playwright tests

* Fix method name

* Update API unit test

* Refactor 'can_build' calculation

- Make it much more efficient
- Reduce code duplication

* Fix unit test

* Adjust serializer type

* Update src/backend/InvenTree/part/models.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/backend/InvenTree/part/test_bom_item.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update docs/docs/manufacturing/bom.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update docs/docs/manufacturing/bom.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Adjust unit test

* Adjust tests

* Tweak requirements

* Tweak playwright tests

* More playwright fixes

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Oliver
2025-07-20 19:14:29 +10:00
committed by GitHub
parent 31d4a88f90
commit 69ca942dfc
23 changed files with 819 additions and 394 deletions

View File

@@ -17,9 +17,11 @@ A BOM for a particular assembly is comprised of a number (zero or more) of BOM "
| Property | Description |
| --- | --- |
| Part | A reference to another *Part* object which is required to build this assembly |
| Quantity | The quantity of *Part* required for the assembly |
| Reference | Optional reference field to describe the BOM Line Item, e.g. part designator |
| Overage | Estimated losses for a build. Can be expressed as absolute values (e.g. 1, 7, etc) or as a percentage (e.g. 2%) |
| Quantity | The quantity of *Part* required for the assembly |
| Attrition | Estimated attrition losses for a production run. Expressed as a percentage of the base quantity (e.g. 2%) |
| Setup Quantity | An additional quantity of the part which is required to account for fixed setup losses during the production process. This is added to the base quantity of the BOM line item |
| Rounding Multiple | A value which indicates that the required quantity should be rounded up to the nearest multiple of this value. |
| Consumable | A boolean field which indicates whether this BOM Line Item is *consumable* |
| Inherited | A boolean field which indicates whether this BOM Line Item will be "inherited" by BOMs for parts which are a variant (or sub-variant) of the part for which this BOM is defined. |
| Optional | A boolean field which indicates if this BOM Line Item is "optional" |
@@ -96,7 +98,7 @@ The `Create BOM Item` form will be displayed:
{{ image("build/bom_add_item.png", "Create BOM Item Form") }}
Fill-out the `Quantity` (required), `Reference`, `Overage` and `Note` (optional) fields then click on <span class="badge inventree confirm">Submit</span> to add the BOM item to this part's BOM.
Fill-out the required fields then click on <span class="badge inventree confirm">Submit</span> to add the BOM item to this part's BOM.
### Add Substitute for BOM Item
@@ -111,3 +113,90 @@ Select a part in the list and click on "Add Substitute" button to confirm.
## Multi Level BOMs
Multi-level (hierarchical) BOMs are natively supported by InvenTree. A Bill of Materials (BOM) can contain sub-assemblies which themselves have a defined BOM. This can continue for an unlimited number of levels.
## Required Quantity Calculation
When a new [Build Order](./build.md) is created, the required production quantity of each component part is calculated based on the BOM line items defined for the assembly being built. To calculate the required production quantity of a component part, the following considerations are made:
### Base Quantity
The base quantity of a BOM line item is defined by the `Quantity` field of the BOM line item. This is the number of parts which are required to build one assembly. This value is multiplied by the number of assemblies which are being built to determine the total quantity of parts required.
```
Required Quantity = Base Quantity * Number of Assemblies
```
### Attrition
The `Attrition` field of a BOM line item is used to account for expected losses during the production process. This is expressed as a percentage of the `Base Quantity` (e.g. 2%).
If a non-zero attrition percentage is specified, it is applied to the calculated `Required Quantity` value.
```
Required Quantity = Required Quantity * (1 + Attrition Percentage)
```
!!! info "Optional"
The attrition percentage is optional. If not specified, it defaults to 0%.
### Setup Quantity
The `Setup Quantity` field of a BOM line item is used to account for fixed losses during the production process. This is an additional quantity of the part which is required to ensure that the production run can be completed successfully. This value is added to the calculated `Required Quantity`.
```
Required Quantity = Required Quantity + Setup Quantity
```
!!! info "Optional"
The setup quantity is optional. If not specified, it defaults to 0.
### Rounding Multiple
The `Rounding Multiple` field of a BOM line item is used to round the calculated `Required Quantity` value to the nearest multiple of the specified value. This is useful for ensuring that the required quantity is a whole number, or to meet specific packaging requirements.
```
Required Quantity = ceil(Required Quantity / Rounding Multiple) * Rounding Multiple
```
!!! info "Optional"
The rounding multiple is optional. If not specified, no rounding is applied to the calculated production quantity.
### Example Calculation
Consider a BOM line item with the following properties:
- Base Quantity: 3
- Attrition: 2% (0.02)
- Setup Quantity: 10
- Rounding Multiple: 25
If we are building 100 assemblies, the required quantity would be calculated as follows:
```
Required Quantity = Base Quantity * Number of Assemblies
= 3 * 100
= 300
Attrition Value = Required Quantity * Attrition Percentage
= 300 * 0.02
= 6
Required Quantity = Required Quantity + Attrition Value
= 300 + 6
= 306
Required Quantity = Required Quantity + Setup Quantity
= 306 + 10
= 316
Required Quantity = ceil(Required Quantity / Rounding Multiple) * Rounding Multiple
= ceil(316 / 25) * 25
= 13 * 25
= 325
```
So the final required production quantity of the component part would be `325`.
!!! info "Calculation"
The required quantity calculation is performed automatically when a new [Build Order](./build.md) is created.

View File

@@ -1,12 +1,17 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 370
INVENTREE_API_VERSION = 371
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v371 -> 2025-07-18 : https://github.com/inventree/InvenTree/pull/10042
- Adds "setup_quantity" and "attrition" fields to BomItem API endpoints
- Remove "overage" field from BomItem API endpoints
- Adds "rounding_multiple" field to BomItem API endpoints
v370 -> 2025-07-17 : https://github.com/inventree/InvenTree/pull/10036
- Adds optional "assembly_detail" information to BuildLine API endpoint
- Adds "include_variants" filter to SalesOrderLineItem API endpoint

View File

@@ -40,7 +40,6 @@ from stock.models import StockItem, StockLocation
from . import config, helpers, ready, schema, status, version
from .tasks import offload_task
from .validators import validate_overage
class TreeFixtureTest(TestCase):
@@ -463,27 +462,6 @@ class ConversionTest(TestCase):
class ValidatorTest(TestCase):
"""Simple tests for custom field validators."""
def test_overage(self):
"""Test overage validator."""
validate_overage('100%')
validate_overage('10')
validate_overage('45.2 %')
with self.assertRaises(django_exceptions.ValidationError):
validate_overage('-1')
with self.assertRaises(django_exceptions.ValidationError):
validate_overage('-2.04 %')
with self.assertRaises(django_exceptions.ValidationError):
validate_overage('105%')
with self.assertRaises(django_exceptions.ValidationError):
validate_overage('xxx %')
with self.assertRaises(django_exceptions.ValidationError):
validate_overage('aaaa')
def test_url_validation(self):
"""Test for AllowedURLValidator."""
from common.models import InvenTreeSetting

View File

@@ -1,7 +1,5 @@
"""Custom field validators for InvenTree."""
from decimal import Decimal, InvalidOperation
from django.conf import settings
from django.core import validators
from django.core.exceptions import ValidationError
@@ -95,46 +93,3 @@ def validate_sales_order_reference(value):
def validate_tree_name(value):
"""Placeholder for legacy function used in migrations."""
def validate_overage(value):
"""Validate that a BOM overage string is properly formatted.
An overage string can look like:
- An integer number ('1' / 3 / 4)
- A decimal number ('0.123')
- A percentage ('5%' / '10 %')
"""
value = str(value).lower().strip()
# First look for a simple numerical value
try:
i = Decimal(value)
if i < 0:
raise ValidationError(_('Overage value must not be negative'))
# Looks like a number
return
except (ValueError, InvalidOperation):
pass
# Now look for a percentage value
if value.endswith('%'):
v = value[:-1].strip()
# Does it look like a number?
try:
f = float(v)
if f < 0:
raise ValidationError(_('Overage value must not be negative'))
elif f > 100:
raise ValidationError(_('Overage must not exceed 100%'))
return
except ValueError:
pass
raise ValidationError(_('Invalid value for overage'))

View File

@@ -1558,7 +1558,7 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo
When a new Build is created, the BuildLine objects are created automatically.
- A BuildLine entry is created for each BOM item associated with the part
- The quantity is set to the quantity required to build the part (including overage)
- The quantity is set to the quantity required to build the part
- BuildItem objects are associated with a particular BuildLine
Once a build has been created, BuildLines can (optionally) be removed from the Build

View File

@@ -1409,6 +1409,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
substitutes=False,
sub_part_detail=False,
part_detail=False,
can_build=False,
)
assembly_detail = part_serializers.PartBriefSerializer(

View File

@@ -1681,14 +1681,12 @@ class BomMixin:
"""
# Do we wish to include extra detail?
try:
kwargs['part_detail'] = str2bool(self.request.GET.get('part_detail', None))
except AttributeError:
pass
params = self.request.query_params
kwargs['can_build'] = str2bool(params.get('can_build', True))
kwargs['part_detail'] = str2bool(params.get('part_detail', False))
kwargs['sub_part_detail'] = str2bool(params.get('sub_part_detail', False))
try:
kwargs['sub_part_detail'] = str2bool(
self.request.GET.get('sub_part_detail', None)
)
except AttributeError:
pass
@@ -1729,6 +1727,9 @@ class BomList(BomMixin, DataExportViewMixin, ListCreateDestroyAPIView):
ordering_fields = [
'can_build',
'quantity',
'setup_quantity',
'attrition',
'rounding_multiple',
'sub_part',
'available_stock',
'allow_variants',

View File

@@ -30,6 +30,7 @@ from django.db.models import (
When,
)
from django.db.models.functions import Cast, Coalesce, Greatest
from django.db.models.query import QuerySet
from sql_util.utils import SubquerySum
@@ -361,6 +362,144 @@ def annotate_sub_categories():
)
def annotate_bom_item_can_build(queryset: QuerySet, reference: str = '') -> QuerySet:
"""Annotate the 'can_build' quantity for each BomItem in a queryset.
Arguments:
queryset: A queryset of BomItem objects
reference: Reference to the BomItem from the current queryset (default = '')
To do this we need to also annotate some other fields which are used in the calculation:
- total_in_stock: Total stock quantity for the part (may include variant stock)
- available_stock: Total available stock quantity for the part
- variant_stock: Total stock quantity for any variant parts
- substitute_stock: Total stock quantity for any substitute parts
And then finally, annotate the 'can_build' quantity for each BomItem:
"""
# Pre-fetch the required related fields
queryset = queryset.prefetch_related(
f'{reference}sub_part',
f'{reference}sub_part__stock_items',
f'{reference}sub_part__stock_items__allocations',
f'{reference}sub_part__stock_items__sales_order_allocations',
f'{reference}substitutes',
f'{reference}substitutes__part__stock_items',
)
# Queryset reference to the linked sub_part instance
sub_part_ref = f'{reference}sub_part__'
# Apply some aliased annotations to the queryset
queryset = queryset.annotate(
# Total stock quantity (just for the sub_part itself)
total_stock=annotate_total_stock(sub_part_ref),
# Total allocated to sales orders
allocated_to_sales_orders=annotate_sales_order_allocations(sub_part_ref),
# Total allocated to build orders
allocated_to_build_orders=annotate_build_order_allocations(sub_part_ref),
)
# Annotate the "available" stock, based on the total stock and allocations
queryset = queryset.annotate(
available_stock=Greatest(
ExpressionWrapper(
F('total_stock')
- F('allocated_to_sales_orders')
- F('allocated_to_build_orders'),
output_field=models.DecimalField(),
),
Decimal(0),
output_field=models.DecimalField(),
)
)
# Annotate the total stock for any variant parts
vq = variant_stock_query(reference=sub_part_ref)
queryset = queryset.alias(
variant_stock_total=annotate_variant_quantity(vq, reference='quantity'),
variant_bo_allocations=annotate_variant_quantity(
vq, reference='sales_order_allocations__quantity'
),
variant_so_allocations=annotate_variant_quantity(
vq, reference='allocations__quantity'
),
)
# Annotate total variant stock
queryset = queryset.annotate(
available_variant_stock=Greatest(
ExpressionWrapper(
F('variant_stock_total')
- F('variant_bo_allocations')
- F('variant_so_allocations'),
output_field=FloatField(),
),
0,
output_field=FloatField(),
)
)
# Account for substitute parts
substitute_ref = f'{reference}substitutes__part__'
# Extract similar information for any 'substitute' parts
queryset = queryset.alias(
substitute_stock=annotate_total_stock(reference=substitute_ref),
substitute_build_allocations=annotate_build_order_allocations(
reference=substitute_ref
),
substitute_sales_allocations=annotate_sales_order_allocations(
reference=substitute_ref
),
)
# Calculate 'available_substitute_stock' field
queryset = queryset.annotate(
available_substitute_stock=Greatest(
ExpressionWrapper(
F('substitute_stock')
- F('substitute_build_allocations')
- F('substitute_sales_allocations'),
output_field=models.DecimalField(),
),
Decimal(0),
output_field=models.DecimalField(),
)
)
# Now we can annotate the total "available" stock for the BomItem
queryset = queryset.alias(
total_stock=ExpressionWrapper(
F('available_variant_stock')
+ F('available_substitute_stock')
+ F('available_stock'),
output_field=FloatField(),
)
)
# And finally, we can annotate the 'can_build' quantity for each BomItem
queryset = queryset.annotate(
can_build=Greatest(
ExpressionWrapper(
Case(
When(Q(quantity=0), then=Value(0)),
default=(F('total_stock') - F('setup_quantity'))
/ (F('quantity') * (1.0 + F('attrition') / 100.0)),
output_field=FloatField(),
),
output_field=FloatField(),
),
Decimal(0),
output_field=FloatField(),
)
)
return queryset
"""A list of valid operators for filtering part parameters."""
PARAMETER_FILTER_OPERATORS: list[str] = ['gt', 'gte', 'lt', 'lte', 'ne', 'icontains']

View File

@@ -38,7 +38,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField(default=1, help_text='BOM quantity for this BOM item', validators=[django.core.validators.MinValueValidator(0)])),
('overage', models.CharField(blank=True, help_text='Estimated build wastage quantity (absolute or percentage)', max_length=24, validators=[InvenTree.validators.validate_overage])),
('overage', models.CharField(blank=True, help_text='Estimated build wastage quantity (absolute or percentage)', max_length=24, validators=[])),
('note', models.CharField(blank=True, help_text='BOM item notes', max_length=100)),
],
options={

View File

@@ -38,7 +38,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='bomitem',
name='overage',
field=models.CharField(blank=True, help_text='Estimated build wastage quantity (absolute or percentage)', max_length=24, validators=[InvenTree.validators.validate_overage], verbose_name='Overage'),
field=models.CharField(blank=True, help_text='Estimated build wastage quantity (absolute or percentage)', max_length=24, validators=[], verbose_name='Overage'),
),
migrations.AlterField(
model_name='bomitem',

View File

@@ -0,0 +1,55 @@
# Generated by Django 4.2.23 on 2025-07-18 23:40
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("part", "0136_partparameter_note_partparameter_updated_and_more"),
]
operations = [
migrations.AddField(
model_name="bomitem",
name="attrition",
field=models.DecimalField(
decimal_places=3,
default=0,
help_text="Estimated attrition for a build, expressed as a percentage (0-100)",
max_digits=6,
validators=[
django.core.validators.MinValueValidator(0),
django.core.validators.MaxValueValidator(100),
],
verbose_name="Attrition",
),
),
migrations.AddField(
model_name="bomitem",
name="rounding_multiple",
field=models.DecimalField(
blank=True,
decimal_places=5,
default=None,
help_text="Round up required production quantity to nearest multiple of this value",
max_digits=15,
null=True,
validators=[django.core.validators.MinValueValidator(0)],
verbose_name="Rounding Multiple",
),
),
migrations.AddField(
model_name="bomitem",
name="setup_quantity",
field=models.DecimalField(
decimal_places=5,
default=0,
help_text="Extra required quantity for a build, to account for setup losses",
max_digits=15,
validators=[django.core.validators.MinValueValidator(0)],
verbose_name="Setup Quantity",
),
),
]

View File

@@ -0,0 +1,88 @@
# Generated by Django 4.2.23 on 2025-07-18 23:42
from decimal import Decimal
from django.db import migrations
def convert_overage(apps, schema_editor):
"""Convert 'overage' field to 'setup_quantity' and 'attrition' fields.
- The 'overage' field is split into two new fields:
- 'setup_quantity': The integer part of the overage.
- 'attrition': The fractional part of the overage.
"""
BomItem = apps.get_model('part', 'BomItem')
# Fetch all BomItem objects with a non-zero overage
bom_items = BomItem.objects.exclude(overage='').exclude(overage=None).distinct()
if bom_items.count() == 0:
return
print(f"\nConverting {bom_items.count()} BomItem objects with 'overage' to 'setup_quantity' and 'attrition'")
for item in bom_items:
# First attempt - convert to a percentage
overage = str(item.overage).strip()
if overage.endswith('%'):
try:
attrition = Decimal(overage[:-1])
attrition = max(0, attrition) # Ensure it's not negative
attrition = min(100, attrition) # Cap at 100%
item.attrition = attrition
item.setup_quantity = Decimal(0)
item.save()
except Exception as e:
print(f" - Error converting {item.pk} from percentage: {e}")
continue
else:
# If not a percentage, treat it as a decimal number
try:
setup_quantity = Decimal(overage)
setup_quantity = max(0, setup_quantity) # Ensure it's not negative
item.setup_quantity = setup_quantity
item.attrition = Decimal(0)
item.save()
except Exception as e:
print(f"- Error converting {item.pk} from decimal: {e}")
continue
def revert_overage(apps, schema_editor):
"""Revert the 'setup_quantity' and 'attrition' fields back to 'overage'.
- Combines 'setup_quantity' and 'attrition' back into the 'overage' field.
"""
BomItem = apps.get_model('part', 'BomItem')
# First, convert all 'attrition' values to percentages
for item in BomItem.objects.exclude(attrition=0).distinct():
item.overage = f"{item.attrition}%"
item.save()
# Second, convert all 'setup_quantity' values to strings
for item in BomItem.objects.exclude(setup_quantity=0).distinct():
item.overage = str(item.setup_quantity or 0)
item.save()
class Migration(migrations.Migration):
dependencies = [
("part", "0137_bomitem_attrition_bomitem_rounding_multiple_and_more"),
]
operations = [
migrations.RunPython(
code=convert_overage,
reverse_code=revert_overage,
)
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 4.2.23 on 2025-07-19 00:22
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("part", "0138_auto_20250718_2342"),
]
operations = [
migrations.RemoveField(
model_name="bomitem",
name="overage",
),
]

View File

@@ -15,10 +15,14 @@ from typing import Optional, cast
from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.core.validators import MinLengthValidator, MinValueValidator
from django.core.validators import (
MaxValueValidator,
MinLengthValidator,
MinValueValidator,
)
from django.db import models, transaction
from django.db.models import ExpressionWrapper, F, Q, QuerySet, Sum, UniqueConstraint
from django.db.models.functions import Coalesce, Greatest
from django.db.models import F, Q, QuerySet, Sum, UniqueConstraint
from django.db.models.functions import Coalesce
from django.db.models.signals import post_delete, post_save
from django.db.utils import IntegrityError
from django.dispatch import receiver
@@ -908,7 +912,7 @@ class Part(
# Generate a query for any stock items for this part variant tree with non-empty serial numbers
if not get_global_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', False):
# Serial numbers are unique acros part trees
# Serial numbers are unique across part trees
stock = stock.filter(part__tree_id=self.tree_id)
# There are no matching StockItem objects (skip further tests)
@@ -1558,115 +1562,28 @@ class Part(
if not self.has_bom:
return 0
total = None
# Prefetch related tables, to reduce query expense
queryset = self.get_bom_items()
# Ignore 'consumable' BOM items for this calculation
queryset = queryset.filter(consumable=False)
queryset = queryset.prefetch_related(
'sub_part__stock_items',
'sub_part__stock_items__allocations',
'sub_part__stock_items__sales_order_allocations',
'substitutes',
'substitutes__part__stock_items',
)
# Annotate the queryset with the 'can_build' quantity
queryset = part.filters.annotate_bom_item_can_build(queryset)
# Annotate the 'available stock' for each part in the BOM
ref = 'sub_part__'
queryset = queryset.alias(
total_stock=part.filters.annotate_total_stock(reference=ref),
so_allocations=part.filters.annotate_sales_order_allocations(reference=ref),
bo_allocations=part.filters.annotate_build_order_allocations(reference=ref),
)
can_build_quantity = None
# Calculate the 'available stock' based on previous annotations
queryset = queryset.annotate(
available_stock=Greatest(
ExpressionWrapper(
F('total_stock') - F('so_allocations') - F('bo_allocations'),
output_field=models.DecimalField(),
),
0,
output_field=models.DecimalField(),
)
)
for value in queryset.values_list('can_build', flat=True):
if can_build_quantity is None:
can_build_quantity = value
else:
can_build_quantity = min(can_build_quantity, value)
# Extract similar information for any 'substitute' parts
ref = 'substitutes__part__'
queryset = queryset.alias(
sub_total_stock=part.filters.annotate_total_stock(reference=ref),
sub_so_allocations=part.filters.annotate_sales_order_allocations(
reference=ref
),
sub_bo_allocations=part.filters.annotate_build_order_allocations(
reference=ref
),
)
if can_build_quantity is None:
# No BOM items, or no items which can be built
return 0
queryset = queryset.annotate(
substitute_stock=Greatest(
ExpressionWrapper(
F('sub_total_stock')
- F('sub_so_allocations')
- F('sub_bo_allocations'),
output_field=models.DecimalField(),
),
0,
output_field=models.DecimalField(),
)
)
# Extract similar information for any 'variant' parts
variant_stock_query = part.filters.variant_stock_query(reference='sub_part__')
queryset = queryset.alias(
var_total_stock=part.filters.annotate_variant_quantity(
variant_stock_query, reference='quantity'
),
var_bo_allocations=part.filters.annotate_variant_quantity(
variant_stock_query, reference='allocations__quantity'
),
var_so_allocations=part.filters.annotate_variant_quantity(
variant_stock_query, reference='sales_order_allocations__quantity'
),
)
queryset = queryset.annotate(
variant_stock=Greatest(
ExpressionWrapper(
F('var_total_stock')
- F('var_bo_allocations')
- F('var_so_allocations'),
output_field=models.DecimalField(),
),
0,
output_field=models.DecimalField(),
)
)
for item in queryset.all():
if item.quantity <= 0:
# Ignore zero-quantity items
continue
# Iterate through each item in the queryset, work out the limiting quantity
quantity = item.available_stock + item.substitute_stock
if item.allow_variants:
quantity += item.variant_stock
n = int(quantity / item.quantity)
if total is None or n < total:
total = n
if total is None:
total = 0
return max(total, 0)
return int(max(can_build_quantity, 0))
@property
def active_builds(self):
@@ -1923,7 +1840,7 @@ class Part(
):
"""Return a BomItem queryset which returns all BomItem instances which refer to *this* part.
As the BOM allocation logic is somewhat complicted, there are some considerations:
As the BOM allocation logic is somewhat complicated, there are some considerations:
A) This part may be directly specified in a BomItem instance
B) This part may be a *variant* of a part which is directly specified in a BomItem instance
@@ -4330,7 +4247,9 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
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)
overage: Estimated losses for a Build. Can be expressed as absolute value (e.g. '7') or a percentage (e.g. '2%')
setup_quantity: Extra required quantity for a build, to account for setup losses
attrition: Estimated losses for a Build, expressed as a percentage (e.g. '2%')
rounding_multiple: Rounding quantity when calculating the required quantity for a build
note: Note field for this BOM item
checksum: Validation checksum for the particular BOM line item
inherited: This BomItem can be inherited by the BOMs of variant parts
@@ -4498,12 +4417,37 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
help_text=_('This BOM item is consumable (it is not tracked in build orders)'),
)
overage = models.CharField(
max_length=24,
setup_quantity = models.DecimalField(
default=0,
max_digits=15,
decimal_places=5,
validators=[MinValueValidator(0)],
verbose_name=_('Setup Quantity'),
help_text=_('Extra required quantity for a build, to account for setup losses'),
)
attrition = models.DecimalField(
default=0,
max_digits=6,
decimal_places=3,
validators=[MinValueValidator(0), MaxValueValidator(100)],
verbose_name=_('Attrition'),
help_text=_(
'Estimated attrition for a build, expressed as a percentage (0-100)'
),
)
rounding_multiple = models.DecimalField(
null=True,
blank=True,
validators=[validators.validate_overage],
verbose_name=_('Overage'),
help_text=_('Estimated build wastage quantity (absolute or percentage)'),
default=None,
max_digits=15,
decimal_places=5,
validators=[MinValueValidator(0)],
verbose_name=_('Rounding Multiple'),
help_text=_(
'Round up required production quantity to nearest multiple of this value'
),
)
reference = models.CharField(
@@ -4567,6 +4511,9 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
self.part.pk,
self.sub_part.pk,
normalize(self.quantity),
self.setup_quantity,
self.attrition,
self.rounding_multiple,
self.reference,
self.optional,
self.inherited,
@@ -4642,60 +4589,66 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
except Part.DoesNotExist:
raise ValidationError({'sub_part': _('Sub part must be specified')})
def get_overage_quantity(self, quantity):
"""Calculate overage quantity."""
# Most of the time overage string will be empty
if len(self.overage) == 0:
return 0
def can_build_quantity(self, available_stock: float) -> int:
"""Calculate the number of assemblies that can be built with the available stock.
overage = str(self.overage).strip()
# Is the overage a numerical value?
try:
ovg = float(overage)
ovg = max(ovg, 0)
return ovg
except ValueError:
pass
# Is the overage a percentage?
if overage.endswith('%'):
overage = overage[:-1].strip()
try:
percent = float(overage) / 100.0
percent = min(percent, 1)
percent = max(percent, 0)
# Must be represented as a decimal
percent = Decimal(percent)
return float(percent * quantity)
except ValueError:
pass
# Default = No overage
return 0
def get_required_quantity(self, build_quantity):
"""Calculate the required part quantity, based on the supplier build_quantity. Includes overage estimate in the returned value.
Args:
build_quantity: Number of parts to build
Arguments:
available_stock: The amount of stock available for this BOM item
Returns:
Quantity required for this build (including overage)
The number of assemblies that can be built with the available stock.
Returns 0 if the available stock is insufficient.
"""
# Account for setup quantity
available_stock = Decimal(max(0, available_stock - self.setup_quantity))
quantity_decimal = Decimal(self.quantity)
attrition_decimal = Decimal(self.attrition) / 100
n = quantity_decimal * (1 + attrition_decimal)
if n <= 0:
return 0.0
return int(available_stock / n)
def get_required_quantity(self, build_quantity: float) -> float:
"""Calculate the required part quantity, based on the supplied build_quantity.
Arguments:
build_quantity: Number of assemblies to build
Returns:
Production quantity required for this component
"""
# Base quantity requirement
base_quantity = self.quantity * build_quantity
required = self.quantity * build_quantity
# Overage requirement
overage_quantity = self.get_overage_quantity(base_quantity)
# Account for attrition
if self.attrition > 0:
try:
# Convert attrition percentage to decimal
attrition = Decimal(self.attrition) / Decimal(100)
required *= 1 + attrition
except Exception:
log_error('bom_item.get_required_quantity')
required = float(base_quantity) + float(overage_quantity)
# Account for setup quantity
if self.setup_quantity > 0:
try:
setup_quantity = Decimal(self.setup_quantity)
required += setup_quantity
except Exception:
log_error('bom_item.get_required_quantity')
# We now have the total requirement
# If a "rounding_multiple" is specified, then round up to the nearest multiple
if self.rounding_multiple and self.rounding_multiple > 0:
try:
round_up = Decimal(self.rounding_multiple)
value = Decimal(required)
value = math.ceil(value / round_up) * round_up
required = float(value)
except InvalidOperation:
log_error('bom_item.get_required_quantity')
return required
@@ -4704,23 +4657,23 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
"""Return the price-range for this BOM item."""
# get internal price setting
use_internal = get_global_setting('PART_BOM_USE_INTERNAL_PRICE', False)
prange = self.sub_part.get_price_range(
p_range = self.sub_part.get_price_range(
self.quantity, internal=use_internal and internal
)
if prange is None:
return prange
if p_range is None:
return p_range
pmin, pmax = prange
p_min, p_max = p_range
if pmin == pmax:
return decimal2money(pmin)
if p_min == p_max:
return decimal2money(p_min)
# Convert to better string representation
pmin = decimal2money(pmin)
pmax = decimal2money(pmax)
p_min = decimal2money(p_min)
p_max = decimal2money(p_max)
return f'{pmin} to {pmax}'
return f'{p_min} to {p_max}'
@receiver(post_save, sender=BomItem, dispatch_uid='update_bom_build_lines')

View File

@@ -8,7 +8,7 @@ from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.core.validators import MinValueValidator
from django.db import IntegrityError, models, transaction
from django.db.models import ExpressionWrapper, F, FloatField, Q
from django.db.models import ExpressionWrapper, F, Q
from django.db.models.functions import Coalesce, Greatest
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
@@ -836,9 +836,6 @@ class PartSerializer(
)
)
# TODO: This could do with some refactoring
# TODO: Note that BomItemSerializer and BuildLineSerializer have very similar code
queryset = queryset.annotate(
ordering=part_filters.annotate_on_order_quantity(),
in_stock=part_filters.annotate_total_stock(),
@@ -1685,11 +1682,13 @@ class BomItemSerializer(
'sub_part',
'reference',
'quantity',
'overage',
'allow_variants',
'inherited',
'optional',
'consumable',
'setup_quantity',
'attrition',
'rounding_multiple',
'note',
'pk',
'part_detail',
@@ -1720,6 +1719,7 @@ class BomItemSerializer(
- part_detail and sub_part_detail serializers are only included if requested.
- This saves a bunch of database requests
"""
can_build = kwargs.pop('can_build', True)
part_detail = kwargs.pop('part_detail', False)
sub_part_detail = kwargs.pop('sub_part_detail', True)
pricing = kwargs.pop('pricing', True)
@@ -1736,6 +1736,9 @@ class BomItemSerializer(
if not sub_part_detail:
self.fields.pop('sub_part_detail', None)
if not can_build:
self.fields.pop('can_build')
if not substitutes:
self.fields.pop('substitutes', None)
@@ -1748,6 +1751,14 @@ class BomItemSerializer(
quantity = InvenTree.serializers.InvenTreeDecimalField(required=True)
setup_quantity = InvenTree.serializers.InvenTreeDecimalField(required=False)
attrition = InvenTree.serializers.InvenTreeDecimalField(required=False)
rounding_multiple = InvenTree.serializers.InvenTreeDecimalField(
required=False, allow_null=True
)
def validate_quantity(self, quantity):
"""Perform validation for the BomItem quantity field."""
if quantity <= 0:
@@ -1877,33 +1888,6 @@ class BomItemSerializer(
building=part_filters.annotate_in_production_quantity(ref)
)
# Calculate "total stock" for the referenced sub_part
# Calculate the "build_order_allocations" for the sub_part
# Note that these fields are only aliased, not annotated
queryset = queryset.alias(
total_stock=part_filters.annotate_total_stock(reference=ref),
allocated_to_sales_orders=part_filters.annotate_sales_order_allocations(
reference=ref
),
allocated_to_build_orders=part_filters.annotate_build_order_allocations(
reference=ref
),
)
# Calculate 'available_stock' based on previously annotated fields
queryset = queryset.annotate(
available_stock=Greatest(
ExpressionWrapper(
F('total_stock')
- F('allocated_to_sales_orders')
- F('allocated_to_build_orders'),
output_field=models.DecimalField(),
),
Decimal(0),
output_field=models.DecimalField(),
)
)
# Calculate 'external_stock'
queryset = queryset.annotate(
external_stock=part_filters.annotate_total_stock(
@@ -1911,74 +1895,8 @@ class BomItemSerializer(
)
)
ref = 'substitutes__part__'
# Extract similar information for any 'substitute' parts
queryset = queryset.alias(
substitute_stock=part_filters.annotate_total_stock(reference=ref),
substitute_build_allocations=part_filters.annotate_build_order_allocations(
reference=ref
),
substitute_sales_allocations=part_filters.annotate_sales_order_allocations(
reference=ref
),
)
# Calculate 'available_substitute_stock' field
queryset = queryset.annotate(
available_substitute_stock=Greatest(
ExpressionWrapper(
F('substitute_stock')
- F('substitute_build_allocations')
- F('substitute_sales_allocations'),
output_field=models.DecimalField(),
),
Decimal(0),
output_field=models.DecimalField(),
)
)
# Annotate the queryset with 'available variant stock' information
variant_stock_query = part_filters.variant_stock_query(reference='sub_part__')
queryset = queryset.alias(
variant_stock_total=part_filters.annotate_variant_quantity(
variant_stock_query, reference='quantity'
),
variant_bo_allocations=part_filters.annotate_variant_quantity(
variant_stock_query, reference='sales_order_allocations__quantity'
),
variant_so_allocations=part_filters.annotate_variant_quantity(
variant_stock_query, reference='allocations__quantity'
),
)
queryset = queryset.annotate(
available_variant_stock=Greatest(
ExpressionWrapper(
F('variant_stock_total')
- F('variant_bo_allocations')
- F('variant_so_allocations'),
output_field=FloatField(),
),
0,
output_field=FloatField(),
)
)
# Annotate the "can_build" quantity
queryset = queryset.alias(
total_stock=ExpressionWrapper(
F('available_variant_stock')
+ F('available_substitute_stock')
+ F('available_stock'),
output_field=FloatField(),
)
).annotate(
can_build=ExpressionWrapper(
F('total_stock') / F('quantity'), output_field=FloatField()
)
)
# Annotate available stock and "can_build" quantities
queryset = part_filters.annotate_bom_item_can_build(queryset)
return queryset

View File

@@ -2480,7 +2480,9 @@ class BomItemTest(InvenTreeAPITestCase):
'inherited',
'note',
'optional',
'overage',
'setup_quantity',
'attrition',
'rounding_multiple',
'pk',
'part',
'quantity',
@@ -2753,6 +2755,49 @@ class BomItemTest(InvenTreeAPITestCase):
self.assertEqual(str(row['Assembly']), '100')
self.assertEqual(str(row['BOM Level']), '1')
def test_can_build(self):
"""Test that the 'can_build' annotation works as expected."""
# Create an assembly part
assembly = Part.objects.create(
name='Assembly Part',
description='A part which can be built',
assembly=True,
component=False,
)
component = Part.objects.create(
name='Component Part',
description='A component part',
assembly=False,
component=True,
)
# Create a BOM item for the assembly
bom_item = BomItem.objects.create(
part=assembly,
sub_part=component,
quantity=10,
setup_quantity=26,
attrition=3,
rounding_multiple=15,
)
# Create some stock items for the component part
StockItem.objects.create(part=component, quantity=5000)
# expected "can build" quantity
N = bom_item.get_required_quantity(1)
self.assertEqual(N, 45)
# Fetch from API
response = self.get(
reverse('api-bom-item-detail', kwargs={'pk': bom_item.pk}),
expected_code=200,
)
can_build = response.data['can_build']
self.assertAlmostEqual(can_build, 482.9, places=1)
class PartAttachmentTest(InvenTreeAPITestCase):
"""Unit tests for the PartAttachment API endpoint."""

View File

@@ -6,6 +6,7 @@ import django.core.exceptions as django_exceptions
from django.db import transaction
from django.test import TestCase
import build.models
import stock.models
from .models import BomItem, BomItemSubstitute, Part
@@ -78,32 +79,12 @@ class BomItemTest(TestCase):
# But with an integer quantity, should be fine
BomItem.objects.create(part=self.bob, sub_part=p, quantity=21)
def test_overage(self):
"""Test that BOM line overages are calculated correctly."""
def test_attrition(self):
"""Test that BOM line attrition values are calculated correctly."""
item = BomItem.objects.get(part=100, sub_part=50)
q = 300
item.quantity = q
# Test empty overage
n = item.get_overage_quantity(q)
self.assertEqual(n, 0)
# Test improper overage
item.overage = 'asf234?'
n = item.get_overage_quantity(q)
self.assertEqual(n, 0)
# Test absolute overage
item.overage = '3'
n = item.get_overage_quantity(q)
self.assertEqual(n, 3)
# Test percentage-based overage
item.overage = '5.0 % '
n = item.get_overage_quantity(q)
self.assertEqual(n, 15)
item.quantity = 300
item.attrition = 5 # 5% attrition
# Calculate total required quantity
# Quantity = 300 (+ 5%)
@@ -113,6 +94,67 @@ class BomItemTest(TestCase):
self.assertEqual(n, 3150)
def test_setup_quantity(self):
"""Test the 'setup_quantity' attribute."""
item = BomItem.objects.get(pk=4)
# Default is 0
self.assertEqual(item.setup_quantity, 0)
self.assertEqual(item.get_required_quantity(1), 3)
self.assertEqual(item.get_required_quantity(10), 30)
item.setup_quantity = 5
item.save()
# Now the required quantity should include the setup quantity
self.assertEqual(item.get_required_quantity(1), 8) # 3 + 5 = 8
self.assertEqual(item.get_required_quantity(10), 35) # 30 + 5 = 35
def test_round_up(self):
"""Test the 'rounding_multiple' attribute."""
item = BomItem.objects.get(pk=4)
# Default is null
self.assertIsNone(item.rounding_multiple)
self.assertEqual(item.get_required_quantity(1), 3) # 3 x 1 = 3
self.assertEqual(item.get_required_quantity(10), 30) # 3 x 10 = 30
self.assertEqual(item.get_required_quantity(25), 75) # 3 x 25 = 75
# Set a round-up multiple
item.rounding_multiple = 17
item.save()
# Now the required quantity should be rounded up to the nearest multiple of 17
self.assertEqual(
item.get_required_quantity(1), 17
) # 3 x 1 = 3, rounded up to nearest multiple of 17
self.assertEqual(
item.get_required_quantity(2), 17
) # 3 x 2 = 6, rounded up to nearest multiple of 17
self.assertEqual(
item.get_required_quantity(5), 17
) # 3 x 5 = 15, rounded up to nearest multiple of 17
self.assertEqual(
item.get_required_quantity(10), 34
) # 3 x 10 = 30, rounded up to nearest multiple of 17
self.assertEqual(
item.get_required_quantity(100), 306
) # 3 x 100 = 300, rounded up to nearest multiple of 17
# Next, let's create a new Build order
bo = build.models.Build.objects.create(
part=item.part, quantity=21, reference='BO-9999', title='Test Build Order'
)
# Build line items have been auto created
lines = bo.build_lines.all().filter(bom_item=item)
self.assertEqual(lines.count(), 1)
line = lines.first()
self.assertEqual(
line.quantity, 68
) # 3 x 21 = 63, rounded up to nearest multiple of 17
def test_item_hash(self):
"""Test BOM item hash encoding."""
item = BomItem.objects.get(part=100, sub_part=50)

View File

@@ -99,8 +99,12 @@ export function ApiFormField({
);
// Coerce the value to a numerical value
const numericalValue: number | '' = useMemo(() => {
let val: number | '' = 0;
const numericalValue: number | null = useMemo(() => {
let val: number | null = 0;
if (value == null) {
return null;
}
switch (definition.field_type) {
case 'integer':
@@ -116,7 +120,7 @@ export function ApiFormField({
}
if (Number.isNaN(val) || !Number.isFinite(val)) {
val = '';
val = null;
}
return val;
@@ -198,10 +202,16 @@ export function ApiFormField({
ref={field.ref}
id={fieldId}
aria-label={`number-field-${field.name}`}
value={numericalValue}
value={numericalValue === null ? '' : numericalValue}
error={definition.error ?? error?.message}
decimalScale={definition.field_type == 'integer' ? 0 : 10}
onChange={(value: number | string | null) => onChange(value)}
onChange={(value: number | string | null) => {
if (value != null && value.toString().trim() === '') {
onChange(null);
} else {
onChange(value);
}
}}
step={1}
/>
);

View File

@@ -29,12 +29,14 @@ export function bomItemFields(): ApiFormFieldSet {
},
quantity: {},
reference: {},
overage: {},
note: {},
setup_quantity: {},
attrition: {},
rounding_multiple: {},
allow_variants: {},
inherited: {},
consumable: {},
optional: {}
optional: {},
note: {}
};
}

View File

@@ -140,14 +140,73 @@ export function BomTable({
const units = record.sub_part_detail?.units;
return (
<Group justify='space-between' grow>
<Text>{quantity}</Text>
{record.overage && <Text size='xs'>+{record.overage}</Text>}
{units && <Text size='xs'>{units}</Text>}
<Group justify='space-between'>
<Group gap='xs'>
<Text>{quantity}</Text>
{record.setup_quantity && record.setup_quantity > 0 && (
<Text size='xs'>{`(+${record.setup_quantity})`}</Text>
)}
{record.attrition && record.attrition > 0 && (
<Text size='xs'>{`(+${record.attrition}%)`}</Text>
)}
</Group>
{units && <Text size='xs'>[{units}]</Text>}
</Group>
);
}
},
{
accessor: 'setup_quantity',
defaultVisible: false,
sortable: true,
render: (record: any) => {
const setup_quantity = record.setup_quantity;
const units = record.sub_part_detail?.units;
if (setup_quantity == null || setup_quantity === 0) {
return '-';
} else {
return (
<Group gap='xs' justify='space-between'>
<Text size='xs'>{formatDecimal(setup_quantity)}</Text>
{units && <Text size='xs'>[{units}]</Text>}
</Group>
);
}
}
},
{
accessor: 'attrition',
defaultVisible: false,
sortable: true,
render: (record: any) => {
const attrition = record.attrition;
if (attrition == null || attrition === 0) {
return '-';
} else {
return <Text size='xs'>{`${formatDecimal(attrition)}%`}</Text>;
}
}
},
{
accessor: 'rounding_multiple',
defaultVisible: false,
sortable: false,
render: (record: any) => {
const units = record.sub_part_detail?.units;
const multiple: number | null = record.round_up_multiple;
if (multiple == null) {
return '-';
} else {
return (
<Group gap='xs' justify='space-between'>
<Text>{formatDecimal(multiple)}</Text>
{units && <Text size='xs'>[{units}]</Text>}
</Group>
);
}
}
},
{
accessor: 'substitutes',
defaultVisible: false,

View File

@@ -392,14 +392,48 @@ export default function BuildLineTable({
defaultVisible: false,
switchable: false,
render: (record: any) => {
// Include information about the BOM item (if available)
const extra: any[] = [];
if (record?.bom_item_detail?.setup_quantity) {
extra.push(
<Text key='setup-quantity' size='sm'>
{t`Setup Quantity`}: {record.bom_item_detail.setup_quantity}
</Text>
);
}
if (record?.bom_item_detail?.attrition) {
extra.push(
<Text key='attrition' size='sm'>
{t`Attrition`}: {record.bom_item_detail.attrition}%
</Text>
);
}
if (record?.bom_item_detail?.rounding_multiple) {
extra.push(
<Text key='rounding-multiple' size='sm'>
{t`Rounding Multiple`}:{' '}
{record.bom_item_detail.rounding_multiple}
</Text>
);
}
// If a build output is specified, use the provided quantity
return (
<Group justify='space-between' wrap='nowrap'>
<Text>{record.requiredQuantity}</Text>
{record?.part_detail?.units && (
<Text size='xs'>[{record.part_detail.units}]</Text>
)}
</Group>
<TableHoverCard
title={t`BOM Information`}
extra={extra}
value={
<Group justify='space-between' wrap='nowrap'>
<Text>{record.requiredQuantity}</Text>
{record?.part_detail?.units && (
<Text size='xs'>[{record.part_detail.units}]</Text>
)}
</Group>
}
/>
);
}
},

View File

@@ -234,6 +234,8 @@ test('Build Order - Allocation', async ({ browser }) => {
await page.getByText('Reel Storage').waitFor();
await page.getByText('R_10K_0805_1%').first().click();
await page.reload();
// The capacitor stock should be fully allocated
const cell = await page.getByRole('cell', { name: /C_1uF_0805/ });
const row = await getRowFromCell(cell);
@@ -278,7 +280,7 @@ test('Build Order - Allocation', async ({ browser }) => {
{
name: 'Blue Widget',
ipn: 'widget.blue',
available: '39',
available: '129',
required: '5',
allocated: '5'
},
@@ -313,7 +315,7 @@ test('Build Order - Allocation', async ({ browser }) => {
// Check for expected buttons on Red Widget
const redWidget = await page.getByRole('cell', { name: 'Red Widget' });
const redRow = await redWidget.locator('xpath=ancestor::tr').first();
const redRow = await getRowFromCell(redWidget);
await redRow.getByLabel(/row-action-menu-/i).click();
await page
@@ -426,3 +428,33 @@ test('Build Order - External', async ({ browser }) => {
await page.getByRole('cell', { name: 'PO0017' }).waitFor();
await page.getByRole('cell', { name: 'PO0018' }).waitFor();
});
test('Build Order - BOM Quantity', async ({ browser }) => {
// Validate required build order quantities (based on BOM values)
const page = await doCachedLogin(browser, { url: 'part/81/bom' });
// Expected quantity values for the BOM items
await page.getByText('15(+50)').waitFor();
await page.getByText('10(+100)').waitFor();
await loadTab(page, 'Part Details');
// Expected "can build" value: 13
const canBuild = await page
.getByRole('cell', { name: 'Can Build' })
.locator('div');
const row = await getRowFromCell(canBuild);
await row.getByText('13').waitFor();
await loadTab(page, 'Build Orders');
await page.getByRole('cell', { name: 'BO0016' }).click();
await loadTab(page, 'Required Parts');
const line = await page
.getByRole('cell', { name: 'Thumbnail R_10K_0805_1%' })
.locator('div');
const row2 = await getRowFromCell(line);
await row2.getByText('1175').waitFor();
});

View File

@@ -207,15 +207,17 @@ test('Stock - Serialize', async ({ browser }) => {
await page.getByLabel('text-field-serial_numbers').fill('200-250');
await page.getByRole('button', { name: 'Submit' }).click();
await page
.getByText('Group range 200-250 exceeds allowed quantity')
.getByText('Number of unique serial numbers (51) must match quantity (100)')
.waitFor();
await page.getByLabel('text-field-serial_numbers').fill('1, 2, 3');
await page.waitForTimeout(250);
await page.getByRole('button', { name: 'Submit' }).click();
await page
.getByText('Number of unique serial numbers (3) must match quantity (10)')
.getByText('Number of unique serial numbers (3) must match quantity (100)')
.waitFor();
await page.getByRole('button', { name: 'Cancel' }).click();