mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 05:05:42 +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:
		| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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')) | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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( | ||||
|   | ||||
| @@ -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', | ||||
|   | ||||
| @@ -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'] | ||||
|  | ||||
|   | ||||
| @@ -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={ | ||||
|   | ||||
| @@ -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', | ||||
|   | ||||
| @@ -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", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @@ -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, | ||||
|         ) | ||||
|     ] | ||||
| @@ -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", | ||||
|         ), | ||||
|     ] | ||||
| @@ -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') | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
|   | ||||
| @@ -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.""" | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user