From 69ca942dfc846216b462230ea48783096d90cf10 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 20 Jul 2025 19:14:29 +1000 Subject: [PATCH] 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> --- docs/docs/manufacturing/bom.md | 95 +++++- .../InvenTree/InvenTree/api_version.py | 7 +- src/backend/InvenTree/InvenTree/tests.py | 22 -- src/backend/InvenTree/InvenTree/validators.py | 45 --- src/backend/InvenTree/build/models.py | 2 +- src/backend/InvenTree/build/serializers.py | 1 + src/backend/InvenTree/part/api.py | 15 +- src/backend/InvenTree/part/filters.py | 139 +++++++++ .../InvenTree/part/migrations/0001_initial.py | 2 +- .../migrations/0064_auto_20210404_2016.py | 2 +- ...tion_bomitem_rounding_multiple_and_more.py | 55 ++++ .../migrations/0138_auto_20250718_2342.py | 88 ++++++ .../migrations/0139_remove_bomitem_overage.py | 17 ++ src/backend/InvenTree/part/models.py | 285 ++++++++---------- src/backend/InvenTree/part/serializers.py | 118 ++------ src/backend/InvenTree/part/test_api.py | 47 ++- src/backend/InvenTree/part/test_bom_item.py | 90 ++++-- .../components/forms/fields/ApiFormField.tsx | 20 +- src/frontend/src/forms/BomForms.tsx | 8 +- src/frontend/src/tables/bom/BomTable.tsx | 67 +++- .../src/tables/build/BuildLineTable.tsx | 46 ++- src/frontend/tests/pages/pui_build.spec.ts | 36 ++- src/frontend/tests/pages/pui_stock.spec.ts | 6 +- 23 files changed, 819 insertions(+), 394 deletions(-) create mode 100644 src/backend/InvenTree/part/migrations/0137_bomitem_attrition_bomitem_rounding_multiple_and_more.py create mode 100644 src/backend/InvenTree/part/migrations/0138_auto_20250718_2342.py create mode 100644 src/backend/InvenTree/part/migrations/0139_remove_bomitem_overage.py diff --git a/docs/docs/manufacturing/bom.md b/docs/docs/manufacturing/bom.md index 6488a939d6..dcbc135d33 100644 --- a/docs/docs/manufacturing/bom.md +++ b/docs/docs/manufacturing/bom.md @@ -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 Submit to add the BOM item to this part's BOM. +Fill-out the required fields then click on Submit 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. diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 91c1adecd4..30d71099b9 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/src/backend/InvenTree/InvenTree/tests.py b/src/backend/InvenTree/InvenTree/tests.py index 9b8f47dd96..987704413d 100644 --- a/src/backend/InvenTree/InvenTree/tests.py +++ b/src/backend/InvenTree/InvenTree/tests.py @@ -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 diff --git a/src/backend/InvenTree/InvenTree/validators.py b/src/backend/InvenTree/InvenTree/validators.py index 6a0fe6cd9d..a44de39446 100644 --- a/src/backend/InvenTree/InvenTree/validators.py +++ b/src/backend/InvenTree/InvenTree/validators.py @@ -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')) diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index da2bc233bf..3b176a8317 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -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 diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index a5f15a6939..0133df3878 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -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( diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index 3a78858122..81f74c971a 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -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', diff --git a/src/backend/InvenTree/part/filters.py b/src/backend/InvenTree/part/filters.py index bb57b4815b..460a6c9c64 100644 --- a/src/backend/InvenTree/part/filters.py +++ b/src/backend/InvenTree/part/filters.py @@ -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'] diff --git a/src/backend/InvenTree/part/migrations/0001_initial.py b/src/backend/InvenTree/part/migrations/0001_initial.py index ff1b6fdbe8..bc7d0a8c9e 100644 --- a/src/backend/InvenTree/part/migrations/0001_initial.py +++ b/src/backend/InvenTree/part/migrations/0001_initial.py @@ -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={ diff --git a/src/backend/InvenTree/part/migrations/0064_auto_20210404_2016.py b/src/backend/InvenTree/part/migrations/0064_auto_20210404_2016.py index 90cc04f885..cf15ab0bea 100644 --- a/src/backend/InvenTree/part/migrations/0064_auto_20210404_2016.py +++ b/src/backend/InvenTree/part/migrations/0064_auto_20210404_2016.py @@ -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', diff --git a/src/backend/InvenTree/part/migrations/0137_bomitem_attrition_bomitem_rounding_multiple_and_more.py b/src/backend/InvenTree/part/migrations/0137_bomitem_attrition_bomitem_rounding_multiple_and_more.py new file mode 100644 index 0000000000..971b20ff89 --- /dev/null +++ b/src/backend/InvenTree/part/migrations/0137_bomitem_attrition_bomitem_rounding_multiple_and_more.py @@ -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", + ), + ), + ] diff --git a/src/backend/InvenTree/part/migrations/0138_auto_20250718_2342.py b/src/backend/InvenTree/part/migrations/0138_auto_20250718_2342.py new file mode 100644 index 0000000000..84f0a41f25 --- /dev/null +++ b/src/backend/InvenTree/part/migrations/0138_auto_20250718_2342.py @@ -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, + ) + ] diff --git a/src/backend/InvenTree/part/migrations/0139_remove_bomitem_overage.py b/src/backend/InvenTree/part/migrations/0139_remove_bomitem_overage.py new file mode 100644 index 0000000000..d41e155a3b --- /dev/null +++ b/src/backend/InvenTree/part/migrations/0139_remove_bomitem_overage.py @@ -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", + ), + ] diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index c3ee9d4533..186b251356 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -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') diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 3d0db4c68c..6ac50521bf 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -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 diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py index 5f70139675..1cca5dd9cf 100644 --- a/src/backend/InvenTree/part/test_api.py +++ b/src/backend/InvenTree/part/test_api.py @@ -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.""" diff --git a/src/backend/InvenTree/part/test_bom_item.py b/src/backend/InvenTree/part/test_bom_item.py index 722daf61da..7c3e40fb70 100644 --- a/src/backend/InvenTree/part/test_bom_item.py +++ b/src/backend/InvenTree/part/test_bom_item.py @@ -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) diff --git a/src/frontend/src/components/forms/fields/ApiFormField.tsx b/src/frontend/src/components/forms/fields/ApiFormField.tsx index b2fcd4cb06..50519f31f9 100644 --- a/src/frontend/src/components/forms/fields/ApiFormField.tsx +++ b/src/frontend/src/components/forms/fields/ApiFormField.tsx @@ -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} /> ); diff --git a/src/frontend/src/forms/BomForms.tsx b/src/frontend/src/forms/BomForms.tsx index c421c0018b..8a7ea4da79 100644 --- a/src/frontend/src/forms/BomForms.tsx +++ b/src/frontend/src/forms/BomForms.tsx @@ -29,12 +29,14 @@ export function bomItemFields(): ApiFormFieldSet { }, quantity: {}, reference: {}, - overage: {}, - note: {}, + setup_quantity: {}, + attrition: {}, + rounding_multiple: {}, allow_variants: {}, inherited: {}, consumable: {}, - optional: {} + optional: {}, + note: {} }; } diff --git a/src/frontend/src/tables/bom/BomTable.tsx b/src/frontend/src/tables/bom/BomTable.tsx index 624c855b08..3d68353330 100644 --- a/src/frontend/src/tables/bom/BomTable.tsx +++ b/src/frontend/src/tables/bom/BomTable.tsx @@ -140,14 +140,73 @@ export function BomTable({ const units = record.sub_part_detail?.units; return ( - - {quantity} - {record.overage && +{record.overage}} - {units && {units}} + + + {quantity} + {record.setup_quantity && record.setup_quantity > 0 && ( + {`(+${record.setup_quantity})`} + )} + {record.attrition && record.attrition > 0 && ( + {`(+${record.attrition}%)`} + )} + + {units && [{units}]} ); } }, + { + 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 ( + + {formatDecimal(setup_quantity)} + {units && [{units}]} + + ); + } + } + }, + { + accessor: 'attrition', + defaultVisible: false, + sortable: true, + render: (record: any) => { + const attrition = record.attrition; + if (attrition == null || attrition === 0) { + return '-'; + } else { + return {`${formatDecimal(attrition)}%`}; + } + } + }, + { + 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 ( + + {formatDecimal(multiple)} + {units && [{units}]} + + ); + } + } + }, { accessor: 'substitutes', defaultVisible: false, diff --git a/src/frontend/src/tables/build/BuildLineTable.tsx b/src/frontend/src/tables/build/BuildLineTable.tsx index c80dbfd73f..a08972d6fc 100644 --- a/src/frontend/src/tables/build/BuildLineTable.tsx +++ b/src/frontend/src/tables/build/BuildLineTable.tsx @@ -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( + + {t`Setup Quantity`}: {record.bom_item_detail.setup_quantity} + + ); + } + + if (record?.bom_item_detail?.attrition) { + extra.push( + + {t`Attrition`}: {record.bom_item_detail.attrition}% + + ); + } + + if (record?.bom_item_detail?.rounding_multiple) { + extra.push( + + {t`Rounding Multiple`}:{' '} + {record.bom_item_detail.rounding_multiple} + + ); + } + // If a build output is specified, use the provided quantity return ( - - {record.requiredQuantity} - {record?.part_detail?.units && ( - [{record.part_detail.units}] - )} - + + {record.requiredQuantity} + {record?.part_detail?.units && ( + [{record.part_detail.units}] + )} + + } + /> ); } }, diff --git a/src/frontend/tests/pages/pui_build.spec.ts b/src/frontend/tests/pages/pui_build.spec.ts index f22248b9a4..6ad9084eaf 100644 --- a/src/frontend/tests/pages/pui_build.spec.ts +++ b/src/frontend/tests/pages/pui_build.spec.ts @@ -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(); +}); diff --git a/src/frontend/tests/pages/pui_stock.spec.ts b/src/frontend/tests/pages/pui_stock.spec.ts index ba218307c6..fd95e29709 100644 --- a/src/frontend/tests/pages/pui_stock.spec.ts +++ b/src/frontend/tests/pages/pui_stock.spec.ts @@ -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();