diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index 604996e909..4d3959cd65 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -369,7 +369,7 @@ jobs: INVENTREE_DB_HOST: "127.0.0.1" INVENTREE_DB_PORT: 5432 INVENTREE_DEBUG: true - INVENTREE_LOG_LEVEL: WARNING + INVENTREE_LOG_LEVEL: INFO INVENTREE_CONSOLE_LOG: false INVENTREE_CACHE_HOST: localhost INVENTREE_PLUGINS_ENABLED: true diff --git a/docs/docs/assets/images/build/bom_invalid.png b/docs/docs/assets/images/build/bom_invalid.png new file mode 100644 index 0000000000..1ecc08f863 Binary files /dev/null and b/docs/docs/assets/images/build/bom_invalid.png differ diff --git a/docs/docs/assets/images/build/bom_validated.png b/docs/docs/assets/images/build/bom_validated.png new file mode 100644 index 0000000000..fffd1a7a25 Binary files /dev/null and b/docs/docs/assets/images/build/bom_validated.png differ diff --git a/docs/docs/manufacturing/bom.md b/docs/docs/manufacturing/bom.md index dcbc135d33..aa74434c6e 100644 --- a/docs/docs/manufacturing/bom.md +++ b/docs/docs/manufacturing/bom.md @@ -114,6 +114,42 @@ Select a part in the list and click on "Add Substitute" button to confirm. 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. +## BOM Validation + +InvenTree maintains a "validated" flag for each assembled part. When set, this flag indicates that the production requirements for this part have been validated, and that the BOM has not been changed since the last validation. + +A BOM "checksum" is stored against each part, which is a hash of the BOM line items associated with that part. This checksum is used to determine whether the BOM has changed since the last validation. Whenever a BOM line item is created, adjusted or deleted, any assemblies which are associated with that BOM must be validated to ensure that the BOM is still valid. + +### BOM Checksum + +The following BOM item fields are used when calculating the BOM checksum: + +- *Assembly ID* - The unique identifier of the assembly associated with the BOM line item. +- *Component ID* - The unique identifier of the component part associated with the BOM line item. +- *Reference* - The reference field of the BOM line item. +- *Quantity* - The quantity of the component part required for the assembly. +- *Attrition* - The attrition percentage of the BOM line item. +- *Setup Quantity* - The setup quantity of the BOM line item. +- *Rounding Multiple* - The rounding multiple of the BOM line item. +- *Consumable* - Whether the BOM line item is consumable. +- *Inherited* - Whether the BOM line item is inherited. +- *Optional* - Whether the BOM line item is optional. +- *Allow Variants* - Whether the BOM line item allows variants. + +If any of these fields are changed, the BOM checksum is recalculated, and any assemblies associated with the BOM are marked as "not validated". + +The user must then manually revalidate the BOM for the assembly/ + +### BOM Validation Status + +To view the "validation" status of an assembled part, navigate to the "Bill of Materials" tab of the part detail page. The validation status is displayed at the top of the BOM table: + +{{ image("build/bom_validated.png", "BOM Validation Status") }} + +If the BOM requires revalidation, the status will be displayed as "Not Validated". Additionally the "Validate BOM' button will be displayed at the top of the BOM table, allowing the user to revalidate the BOM. + +{{ image("build/bom_invalid.png", "BOM Not Validated") }} + ## 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: diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 30d71099b9..67d5757464 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,12 +1,15 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 371 +INVENTREE_API_VERSION = 372 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v372 -> 2025-07-19 : https://github.com/inventree/InvenTree/pull/10056 + - Adds BOM validation information to the Part API + 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 diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index 81f74c971a..73c50b6957 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -11,7 +11,6 @@ from django_filters.rest_framework import DjangoFilterBackend from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field from rest_framework import serializers -from rest_framework.exceptions import ValidationError from rest_framework.response import Response import InvenTree.permissions @@ -617,32 +616,8 @@ class PartCopyBOM(CreateAPI): class PartValidateBOM(RetrieveUpdateAPI): """API endpoint for 'validating' the BOM for a given Part.""" - class BOMValidateSerializer(serializers.ModelSerializer): - """Simple serializer class for validating a single BomItem instance.""" - - class Meta: - """Metaclass defines serializer fields.""" - - model = Part - fields = ['checksum', 'valid'] - - checksum = serializers.CharField(read_only=True, source='bom_checksum') - - valid = serializers.BooleanField( - write_only=True, - default=False, - label=_('Valid'), - help_text=_('Validate entire Bill of Materials'), - ) - - def validate_valid(self, valid): - """Check that the 'valid' input was flagged.""" - if not valid: - raise ValidationError(_('This option must be selected')) - queryset = Part.objects.all() - - serializer_class = BOMValidateSerializer + serializer_class = part_serializers.PartBomValidateSerializer def update(self, request, *args, **kwargs): """Validate the referenced BomItem instance.""" @@ -656,9 +631,14 @@ class PartValidateBOM(RetrieveUpdateAPI): serializer = self.get_serializer(part, data=data, partial=partial) serializer.is_valid(raise_exception=True) - part.validate_bom(request.user) + valid = str2bool(serializer.validated_data.get('valid', False)) - return Response({'checksum': part.bom_checksum}) + part.validate_bom(request.user, valid=valid) + + # Re-serialize the response + serializer = self.get_serializer(part, many=False) + + return Response(serializer.data) class PartFilter(rest_filters.FilterSet): @@ -883,24 +863,9 @@ class PartFilter(rest_filters.FilterSet): ) bom_valid = rest_filters.BooleanFilter( - label=_('BOM Valid'), method='filter_bom_valid' + label=_('BOM Valid'), field_name='bom_validated' ) - def filter_bom_valid(self, queryset, name, value): - """Filter by whether the BOM for the part is valid or not.""" - # Limit queryset to active assemblies - queryset = queryset.filter(active=True, assembly=True).distinct() - - # Iterate through the queryset - # TODO: We should cache BOM checksums to make this process more efficient - pks = [] - - for item in queryset: - if item.is_bom_valid() == value: - pks.append(item.pk) - - return queryset.filter(pk__in=pks) - starred = rest_filters.BooleanFilter(label='Starred', method='filter_starred') def filter_starred(self, queryset, name, value): diff --git a/src/backend/InvenTree/part/migrations/0140_part_bom_validated.py b/src/backend/InvenTree/part/migrations/0140_part_bom_validated.py new file mode 100644 index 0000000000..7accbafacc --- /dev/null +++ b/src/backend/InvenTree/part/migrations/0140_part_bom_validated.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.23 on 2025-07-22 01:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("part", "0139_remove_bomitem_overage"), + ] + + operations = [ + migrations.AddField( + model_name="part", + name="bom_validated", + field=models.BooleanField( + default=False, + help_text="Is the BOM for this part valid?", + verbose_name="BOM Validated", + ), + ), + ] diff --git a/src/backend/InvenTree/part/migrations/0141_auto_20250722_0303.py b/src/backend/InvenTree/part/migrations/0141_auto_20250722_0303.py new file mode 100644 index 0000000000..1b0496dd27 --- /dev/null +++ b/src/backend/InvenTree/part/migrations/0141_auto_20250722_0303.py @@ -0,0 +1,80 @@ +# Generated by Django 4.2.23 on 2025-07-22 03:03 + +from django.db import migrations + +def cache_bom_valid(apps, schema_editor): + """Calculate and cache the BOM validity for all parts. + + Procedure: + + - Find all parts which have linked BOM item(s) + - Limit to parts which have a stored BOM checksum + - For each such part, calculate and update the BOM "validity" + """ + + from InvenTree.tasks import offload_task + from part.tasks import check_bom_valid + + Part = apps.get_model('part', 'Part') + BomItem = apps.get_model('part', 'BomItem') + + # Fetch all BomItem objects + bom_items = BomItem.objects.exclude(part=None).prefetch_related('part').distinct() + + parts_to_update = set() + + for item in bom_items: + + # Parts associated with this BomItem + parts = [] + + if item.inherited: + # Find all inherited assemblies for this BomItem + parts = list( + Part.objects.filter( + tree_id=item.part.tree_id, + lft__gte=item.part.lft, + rght__lte=item.part.rght + ) + ) + else: + parts = [item.part] + + for part in parts: + # Part has already been observed - skip + if part in parts_to_update: + continue + + # Part has no BOM checksum - skip + if not part.bom_checksum: + continue + + # Part has not already been validated + if not part.bom_checked_date: + continue + + parts_to_update.add(part) + + if len(parts_to_update) > 0: + print(f"\nScheduling {len(parts_to_update)} parts to update BOM validity.") + + for part in parts_to_update: + # Offload task to recalculate the BOM checksum for this part + # The background worker will process these when the server restarts + offload_task( + check_bom_valid, + part.pk, + force_async=True, + group='part' + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("part", "0140_part_bom_validated"), + ] + + operations = [ + migrations.RunPython(cache_bom_valid, migrations.RunPython.noop), + ] diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index 186b251356..08981548a1 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -453,6 +453,12 @@ class Part( creation_user: User who added this part to the database responsible_owner: Owner (either user or group) which is responsible for this part (optional) last_stocktake: Date at which last stocktake was performed for this Part + + BOM (Bill of Materials) related attributes: + bom_checksum: Checksum for the BOM of this part + bom_validated: Boolean field indicating if the BOM is valid (checksum matches) + bom_checked_by: User who last checked the BOM for this part + bom_checked_date: Date when the BOM was last checked """ NODE_PARENT_KEY = 'variant_of' @@ -1265,6 +1271,12 @@ class Part( help_text=_('Is this a virtual part, such as a software product or license?'), ) + bom_validated = models.BooleanField( + default=False, + verbose_name=_('BOM Validated'), + help_text=_('Is the BOM for this part valid?'), + ) + bom_checksum = models.CharField( max_length=128, blank=True, @@ -1942,31 +1954,50 @@ class Part( result_hash = hashlib.md5(str(self.id).encode()) # List *all* BOM items (including inherited ones!) - bom_items = self.get_bom_items().all().prefetch_related('sub_part') + bom_items = self.get_bom_items().all().prefetch_related('part', 'sub_part') for item in bom_items: result_hash.update(str(item.get_item_hash()).encode()) return str(result_hash.digest()) - def is_bom_valid(self): - """Check if the BOM is 'valid' - if the calculated checksum matches the stored value.""" - return self.get_bom_hash() == self.bom_checksum or not self.has_bom + def is_bom_valid(self) -> bool: + """Check if the BOM is 'valid'. + + To be "valid", the part must: + - Have a stored "bom_checksum" value + - The stored "bom_checksum" must match the calculated checksum. + + Returns: + bool: True if the BOM is valid, False otherwise + """ + if not self.bom_checksum or not self.bom_checked_date: + # If there is no BOM checksum, then the BOM is not valid + return False + + return self.get_bom_hash() == self.bom_checksum @transaction.atomic - def validate_bom(self, user): + def validate_bom(self, user, valid: bool = True): """Validate the BOM (mark the BOM as validated by the given User. + Arguments: + user: User who is validating the BOM + valid: If True, mark the BOM as valid (default=True) + - Calculates and stores the hash for the BOM - Saves the current date and the checking user """ # Validate each line item, ignoring inherited ones - bom_items = self.get_bom_items(include_inherited=False) + bom_items = self.get_bom_items(include_inherited=False).prefetch_related( + 'part', 'sub_part' + ) for item in bom_items: - item.validate_hash() + item.validate_hash(valid=valid) - self.bom_checksum = self.get_bom_hash() + self.bom_validated = valid + self.bom_checksum = self.get_bom_hash() if valid else '' self.bom_checked_by = user self.bom_checked_date = InvenTree.helpers.current_date() @@ -4252,6 +4283,7 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel): 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 + validated: Boolean field indicating if this BOM item is valid (checksum matches) inherited: This BomItem can be inherited by the BOMs of variant parts allow_variants: Stock for part variants can be substituted for this BomItem """ @@ -4329,26 +4361,61 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel): def delete(self): """Check if this item can be deleted.""" + import part.tasks as part_tasks + self.check_part_lock(self.part) + + assemblies = self.get_assemblies() super().delete() + for assembly in assemblies: + # Offload task to update the checksum for this assembly + InvenTree.tasks.offload_task( + part_tasks.check_bom_valid, assembly.pk, group='part' + ) + def save(self, *args, **kwargs): """Enforce 'clean' operation when saving a BomItem instance.""" + import part.tasks as part_tasks + self.clean() - self.check_part_lock(self.part) + check_lock = kwargs.pop('check_lock', True) + + if check_lock: + self.check_part_lock(self.part) + + db_instance = self.get_db_instance() # Check if the part was changed deltas = self.get_field_deltas() if 'part' in deltas and (old_part := deltas['part'].get('old', None)): - self.check_part_lock(old_part) + if check_lock: + self.check_part_lock(old_part) # Update the 'validated' field based on checksum calculation self.validated = self.is_line_valid super().save(*args, **kwargs) + # Do we need to recalculate the BOM hash for assemblies? + if not db_instance or any(f in deltas for f in self.hash_fields()): + # If this is a new BomItem, or if any of the fields used to calculate the hash have changed, + # then we need to recalculate the BOM checksum for all assemblies which use this BomItem + + assemblies = set() + + if db_instance: + # Find all assemblies which use this BomItem *after* we save + assemblies.update(db_instance.get_assemblies()) + + for assembly in assemblies: + # Offload task to update the checksum for this assembly + InvenTree.tasks.offload_task( + part_tasks.check_bom_valid, assembly.pk, group='part' + ) + def check_part_lock(self, assembly): """When editing or deleting a BOM item, check if the assembly is locked. @@ -4490,39 +4557,57 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel): help_text=_('Stock items for variant parts can be used for this BOM item'), ) - def get_item_hash(self): - """Calculate the checksum hash of this BOM line item. + def hash_fields(self) -> list[str]: + """Return a list of fields to be used for hashing this BOM item. - The hash is calculated from the following fields: - - part.pk - - sub_part.pk - - quantity - - reference - - optional - - inherited - - consumable - - allow_variants + These fields are used to calculate the checksum hash of this BOM item. """ + return [ + 'part_id', + 'sub_part_id', + 'quantity', + 'setup_quantity', + 'attrition', + 'rounding_multiple', + 'reference', + 'optional', + 'inherited', + 'consumable', + 'allow_variants', + ] + + def get_item_hash(self) -> str: + """Calculate the checksum hash of this BOM line item.""" # Seed the hash with the ID of this BOM item result_hash = hashlib.md5(b'') - # The following components are used to calculate the checksum - components = [ - self.part.pk, - self.sub_part.pk, - normalize(self.quantity), - self.setup_quantity, - self.attrition, - self.rounding_multiple, - self.reference, - self.optional, - self.inherited, - self.consumable, - self.allow_variants, - ] + for field in self.hash_fields(): + # Get the value of the field + value = getattr(self, field, None) - for component in components: - result_hash.update(str(component).encode()) + # If the value is None, use an empty string + if value is None: + value = '' + + # Normalize decimal values to ensure consistent representation + # These values are only included if they are non-zero + # This is to provide some backwards compatibility from before these fields were addede + if value is not None and field in [ + 'quantity', + 'attrition', + 'setup_quantity', + 'rounding_multiple', + ]: + try: + value = normalize(value) + + if not value or value <= 0: + continue + except Exception: + pass + + # Update the hash with the string representation of the value + result_hash.update(str(value).encode()) return str(result_hash.digest()) @@ -4537,7 +4622,8 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel): else: self.checksum = '' - self.save() + # Save the BOM item (bypass lock check) + self.save(check_lock=False) @property def is_line_valid(self): diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 6ac50521bf..963199a9d3 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -1210,6 +1210,44 @@ class PartSerializer( return self.instance +class PartBomValidateSerializer(InvenTree.serializers.InvenTreeModelSerializer): + """Serializer for Part BOM information.""" + + class Meta: + """Metaclass options.""" + + model = Part + fields = [ + 'pk', + 'bom_validated', + 'bom_checksum', + 'bom_checked_by', + 'bom_checked_by_detail', + 'bom_checked_date', + 'valid', + ] + + read_only_fields = [ + 'bom_validated', + 'bom_checksum', + 'bom_checked_by', + 'bom_checked_by_detail', + 'bom_checked_date', + ] + + valid = serializers.BooleanField( + write_only=True, + default=False, + required=False, + label=_('Valid'), + help_text=_('Validate entire Bill of Materials'), + ) + + bom_checked_by_detail = UserSerializer( + source='bom_checked_by', many=False, read_only=True, allow_null=True + ) + + class PartRequirementsSerializer(InvenTree.serializers.InvenTreeModelSerializer): """Serializer for Part requirements.""" diff --git a/src/backend/InvenTree/part/tasks.py b/src/backend/InvenTree/part/tasks.py index 15349e92e1..9a09d7f98b 100644 --- a/src/backend/InvenTree/part/tasks.py +++ b/src/backend/InvenTree/part/tasks.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta from django.core.exceptions import ValidationError +from django.db.models import Model from django.utils.translation import gettext_lazy as _ import structlog @@ -10,16 +11,12 @@ from opentelemetry import trace import common.currency import common.notifications -import company.models import InvenTree.helpers_model -import InvenTree.tasks -import part.models as part_models -import part.stocktake -import stock.models as stock_models from common.settings import get_global_setting from InvenTree.tasks import ( ScheduledTask, check_daily_holdoff, + offload_task, record_task_success, scheduled_task, ) @@ -29,7 +26,7 @@ logger = structlog.get_logger('inventree') @tracer.start_as_current_span('notify_low_stock') -def notify_low_stock(part: part_models.Part): +def notify_low_stock(part: Model): """Notify interested users that a part is 'low stock'. Rules: @@ -135,9 +132,11 @@ def notify_low_stock_if_required(part_id: int): If true, notify the users who have subscribed to the part """ + from part.models import Part + try: - part = part_models.Part.objects.get(pk=part_id) - except part_models.Part.DoesNotExist: + part = Part.objects.get(pk=part_id) + except Part.DoesNotExist: logger.warning( 'notify_low_stock_if_required: Part with ID %s does not exist', part_id ) @@ -148,7 +147,7 @@ def notify_low_stock_if_required(part_id: int): for p in parts: if p.is_part_low_on_stock(): - InvenTree.tasks.offload_task(notify_low_stock, p, group='notification') + offload_task(notify_low_stock, p, group='notification') @tracer.start_as_current_span('check_stale_stock') @@ -163,6 +162,8 @@ def check_stale_stock(): to notifications for the respective parts. Each user receives one consolidated email containing all their stale stock items. """ + from stock.models import StockItem + # Check if stock expiry functionality is enabled if not get_global_setting('STOCK_ENABLE_EXPIRY', False, cache=False): logger.info('Stock expiry functionality is not enabled - exiting') @@ -179,8 +180,8 @@ def check_stale_stock(): stale_threshold = today + timedelta(days=stale_days) # Find stock items that are stale (expiry date within STOCK_STALE_DAYS) - stale_stock_items = stock_models.StockItem.objects.filter( - stock_models.StockItem.IN_STOCK_FILTER, # Only in-stock items + stale_stock_items = StockItem.objects.filter( + StockItem.IN_STOCK_FILTER, # Only in-stock items expiry_date__isnull=False, # Must have an expiry date expiry_date__lt=stale_threshold, # Expiry date is within stale threshold ).select_related('part', 'location') # Optimize queries @@ -192,7 +193,7 @@ def check_stale_stock(): logger.info('Found %s stale stock items', stale_stock_items.count()) # Group stale stock items by user subscriptions - user_stale_items: dict[stock_models.StockItem, list[stock_models.StockItem]] = {} + user_stale_items: dict[StockItem, list[StockItem]] = {} for stock_item in stale_stock_items: # Get all subscribers for this part @@ -206,9 +207,7 @@ def check_stale_stock(): # Send one consolidated notification per user for user, items in user_stale_items.items(): try: - InvenTree.tasks.offload_task( - notify_stale_stock, user, items, group='notification' - ) + offload_task(notify_stale_stock, user, items, group='notification') except Exception as e: logger.error( 'Error scheduling stale stock notification for user %s: %s', @@ -222,7 +221,7 @@ def check_stale_stock(): @tracer.start_as_current_span('update_part_pricing') -def update_part_pricing(pricing: part_models.PartPricing, counter: int = 0): +def update_part_pricing(pricing: Model, counter: int = 0): """Update cached pricing data for the specified PartPricing instance. Arguments: @@ -251,6 +250,8 @@ def check_missing_pricing(limit=250): Arguments: limit: Maximum number of parts to process at once """ + from part.models import Part, PartPricing + # Find any parts which have 'old' pricing information days = int(get_global_setting('PRICING_UPDATE_DAYS', 30)) @@ -259,7 +260,7 @@ def check_missing_pricing(limit=250): return # Find parts for which pricing information has never been updated - results = part_models.PartPricing.objects.filter(updated=None)[:limit] + results = PartPricing.objects.filter(updated=None)[:limit] if results.count() > 0: logger.info('Found %s parts with empty pricing', results.count()) @@ -269,7 +270,7 @@ def check_missing_pricing(limit=250): stale_date = datetime.now().date() - timedelta(days=days) - results = part_models.PartPricing.objects.filter(updated__lte=stale_date)[:limit] + results = PartPricing.objects.filter(updated__lte=stale_date)[:limit] if results.count() > 0: logger.info('Found %s stale pricing entries', results.count()) @@ -279,7 +280,7 @@ def check_missing_pricing(limit=250): # Find any pricing data which is in the wrong currency currency = common.currency.currency_code_default() - results = part_models.PartPricing.objects.exclude(currency=currency) + results = PartPricing.objects.exclude(currency=currency) if results.count() > 0: logger.info('Found %s pricing entries in the wrong currency', results.count()) @@ -288,7 +289,7 @@ def check_missing_pricing(limit=250): pp.schedule_for_update() # Find any parts which do not have pricing information - results = part_models.Part.objects.filter(pricing_data=None)[:limit] + results = Part.objects.filter(pricing_data=None)[:limit] if results.count() > 0: logger.info('Found %s parts without pricing', results.count()) @@ -309,12 +310,15 @@ def scheduled_stocktake_reports(): - Delete 'old' stocktake report files after the specified period - Generate new reports at the specified period """ + import part.stocktake + from part.models import PartStocktakeReport + # First let's delete any old stocktake reports delete_n_days = int( get_global_setting('STOCKTAKE_DELETE_REPORT_DAYS', 30, cache=False) ) threshold = datetime.now() - timedelta(days=delete_n_days) - old_reports = part_models.PartStocktakeReport.objects.filter(date__lt=threshold) + old_reports = PartStocktakeReport.objects.filter(date__lt=threshold) if old_reports.count() > 0: logger.info('Deleting %s stale stocktake reports', old_reports.count()) @@ -349,12 +353,14 @@ def rebuild_parameters(template_id): This function is called when a base template is changed, which may cause the base unit to be adjusted. """ + from part.models import PartParameter, PartParameterTemplate + try: - template = part_models.PartParameterTemplate.objects.get(pk=template_id) - except part_models.PartParameterTemplate.DoesNotExist: + template = PartParameterTemplate.objects.get(pk=template_id) + except PartParameterTemplate.DoesNotExist: return - parameters = part_models.PartParameter.objects.filter(template=template) + parameters = PartParameter.objects.filter(template=template) n = 0 @@ -373,18 +379,21 @@ def rebuild_parameters(template_id): @tracer.start_as_current_span('rebuild_supplier_parts') -def rebuild_supplier_parts(part_id): +def rebuild_supplier_parts(part_id: int): """Rebuild all SupplierPart objects for a given part. This function is called when a bart part is changed, which may cause the native units of any supplier parts to be updated """ + from company.models import SupplierPart + from part.models import Part + try: - prt = part_models.Part.objects.get(pk=part_id) - except part_models.Part.DoesNotExist: + prt = Part.objects.get(pk=part_id) + except Part.DoesNotExist: return - supplier_parts = company.models.SupplierPart.objects.filter(part=prt) + supplier_parts = SupplierPart.objects.filter(part=prt) n = supplier_parts.count() @@ -398,3 +407,25 @@ def rebuild_supplier_parts(part_id): if n > 0: logger.info("Rebuilt %s supplier parts for part '%s'", n, prt.name) + + +@tracer.start_as_current_span('check_bom_valid') +def check_bom_valid(part_id: int): + """Recalculate the BOM checksum for all assemblies which include the specified Part. + + Arguments: + part_id: The ID of the part for which to recalculate the BOM checksum. + """ + from part.models import Part + + try: + part = Part.objects.get(pk=part_id) + except Part.DoesNotExist: + logger.warning('check_bom_valid: Part with ID %s does not exist', part_id) + return + + valid = part.is_bom_valid() + + if valid != part.bom_validated: + part.bom_validated = valid + part.save() diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py index 1cca5dd9cf..bea0a1ee62 100644 --- a/src/backend/InvenTree/part/test_api.py +++ b/src/backend/InvenTree/part/test_api.py @@ -911,20 +911,89 @@ class PartAPITest(PartAPITestBase): """Test the 'bom_valid' Part API filter.""" url = reverse('api-part-list') - n = Part.objects.filter(active=True, assembly=True).count() + # Create a new assembly + assembly = Part.objects.create( + name='Test Assembly', + description='A test assembly with a valid BOM', + category=PartCategory.objects.first(), + assembly=True, + active=True, + ) + + sub_part = Part.objects.create( + name='Sub Part', + description='A sub part for the assembly', + category=PartCategory.objects.first(), + component=True, + assembly=False, + active=True, + ) + + assembly.refresh_from_db() + sub_part.refresh_from_db() + + # Link the sub part to the assembly via a BOM + bom_item = BomItem.objects.create(part=assembly, sub_part=sub_part, quantity=10) + + filters = {'active': True, 'assembly': True, 'bom_valid': True} # Initially, there are no parts with a valid BOM - response = self.get(url, {'bom_valid': False}, expected_code=200) - n1 = len(response.data) + response = self.get(url, filters) - for item in response.data: - self.assertTrue(item['assembly']) - self.assertTrue(item['active']) + self.assertEqual(len(response.data), 0) - response = self.get(url, {'bom_valid': True}, expected_code=200) - n2 = len(response.data) + # Validate the BOM assembly + assembly.validate_bom(self.user) - self.assertEqual(n1 + n2, n) + response = self.get(url, filters) + + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]['pk'], assembly.pk) + + # Adjust the 'quantity' of the BOM item to make it invalid + bom_item.quantity = 15 + bom_item.save() + + response = self.get(url, filters) + self.assertEqual(len(response.data), 0) + + # Adjust it back again - should be valid again + bom_item.quantity = 10 + bom_item.save() + + response = self.get(url, filters) + self.assertEqual(len(response.data), 1) + + # Test the BOM validation API endpoint + bom_url = reverse('api-part-bom-validate', kwargs={'pk': assembly.pk}) + data = self.get(bom_url, expected_code=200).data + + self.assertEqual(data['bom_validated'], True) + self.assertEqual(data['bom_checked_by'], self.user.pk) + self.assertEqual(data['bom_checked_by_detail']['username'], self.user.username) + self.assertIsNotNone(data['bom_checked_date']) + + # Now, let's try to validate and invalidate the assembly BOM via the API + bom_item.quantity = 99 + bom_item.save() + + data = self.get(bom_url, expected_code=200).data + self.assertEqual(data['bom_validated'], False) + + self.patch(bom_url, {'valid': True}, expected_code=200) + data = self.get(bom_url, expected_code=200).data + self.assertEqual(data['bom_validated'], True) + + assembly.refresh_from_db() + self.assertTrue(assembly.bom_validated) + + # And, we can also invalidate the BOM via the API + self.patch(bom_url, {'valid': False}, expected_code=200) + data = self.get(bom_url, expected_code=200).data + self.assertEqual(data['bom_validated'], False) + + assembly.refresh_from_db() + self.assertFalse(assembly.bom_validated) def test_filter_by_starred(self): """Test by 'starred' filter.""" diff --git a/src/frontend/src/components/dashboard/DashboardWidgetLibrary.tsx b/src/frontend/src/components/dashboard/DashboardWidgetLibrary.tsx index 4054aa7feb..aa2c414cdd 100644 --- a/src/frontend/src/components/dashboard/DashboardWidgetLibrary.tsx +++ b/src/frontend/src/components/dashboard/DashboardWidgetLibrary.tsx @@ -33,8 +33,18 @@ export function BuiltinQueryCountWidgets(): DashboardWidgetProps[] { modelType: ModelType.partcategory, params: { starred: true } }), + QueryCountDashboardWidget({ + label: 'invalid-bom', + title: t`Invalid BOMs`, + description: t`Assemblies requiring bill of materials validation`, + modelType: ModelType.part, + params: { + active: true, // Only show active parts + assembly: true, // Only show parts which are assemblies + bom_valid: false // Only show parts with invalid BOMs + } + }), // TODO: 'latest parts' - // TODO: 'BOM waiting validation' // TODO: 'recently updated stock' QueryCountDashboardWidget({ title: t`Low Stock`, diff --git a/src/frontend/src/components/dashboard/widgets/QueryCountDashboardWidget.tsx b/src/frontend/src/components/dashboard/widgets/QueryCountDashboardWidget.tsx index e0da07a1f5..af38a5ce88 100644 --- a/src/frontend/src/components/dashboard/widgets/QueryCountDashboardWidget.tsx +++ b/src/frontend/src/components/dashboard/widgets/QueryCountDashboardWidget.tsx @@ -95,7 +95,7 @@ function QueryCountWidget({ }, [query.isFetching, query.isError, query.data]); return ( - + } title={t`Validate BOM`}> + {t`Do you want to validate the bill of materials for this assembly?`} + + ), + successMessage: t`BOM validated`, + onFormSuccess: () => { + bomInformationQuery.refetch(); + } + }); + + // Display information about the "validation" state of the BOM for this assembly + const bomValidIcon: ReactNode = useMemo(() => { + if (bomInformationQuery.isFetching) { + return ; + } + + let icon: ReactNode; + let color: MantineColor; + let title = ''; + let description = ''; + + if (bomInformation?.bom_validated) { + color = 'green'; + icon = ; + title = t`BOM Validated`; + description = t`The Bill of Materials for this part has been validated`; + } else if (bomInformation?.bom_checked_date) { + color = 'yellow'; + icon = ; + title = t`BOM Not Validated`; + description = t`The Bill of Materials for this part has previously been checked, but requires revalidation`; + } else { + color = 'red'; + icon = ; + title = t`BOM Not Validated`; + description = t`The Bill of Materials for this part has not yet been validated`; + } + + return ( + + {!bomInformation.bom_validated && ( + } + color='green' + tooltip={t`Validate BOM`} + onClick={validateBom.open} + /> + )} + + + + {icon} + + + + + + {description} + {bomInformation?.bom_checked_date && ( + + {t`Validated On`}: {bomInformation.bom_checked_date} + + )} + {bomInformation?.bom_checked_by_detail && ( + + {t`Validated By`}: + + + )} + + + + + + ); + }, [bomInformation, bomInformationQuery.isFetching]); + // Part data panels (recalculate when part data changes) const partPanels: PanelType[] = useMemo(() => { return [ @@ -712,6 +825,7 @@ export default function PartDetail() { { name: 'bom', label: t`Bill of Materials`, + controls: bomValidIcon, icon: , hidden: !part.assembly, content: part?.pk ? ( @@ -818,7 +932,15 @@ export default function PartDetail() { model_id: part?.pk }) ]; - }, [id, part, user, globalSettings, userSettings, detailsPanel]); + }, [ + id, + part, + user, + bomValidIcon, + globalSettings, + userSettings, + detailsPanel + ]); const breadcrumbs = useMemo(() => { return [ @@ -1065,6 +1187,7 @@ export default function PartDetail() { <> {editPart.modal} {deletePart.modal} + {validateBom.modal} {duplicatePart.modal} {orderPartsWizard.wizard} {findBySerialNumber.modal} diff --git a/src/frontend/src/tables/bom/BomTable.tsx b/src/frontend/src/tables/bom/BomTable.tsx index 3d68353330..d2e4c40a4e 100644 --- a/src/frontend/src/tables/bom/BomTable.tsx +++ b/src/frontend/src/tables/bom/BomTable.tsx @@ -1,9 +1,10 @@ import { t } from '@lingui/core/macro'; -import { Alert, Group, Stack, Text } from '@mantine/core'; +import { ActionIcon, Alert, Group, Stack, Text, Tooltip } from '@mantine/core'; import { showNotification } from '@mantine/notifications'; import { IconArrowRight, IconCircleCheck, + IconExclamationCircle, IconFileArrowLeft, IconLock, IconSwitch3 @@ -34,7 +35,6 @@ import { formatDecimal, formatPriceRange } from '../../defaults/formatters'; import { bomItemFields, useEditBomSubstitutesForm } from '../../forms/BomForms'; import { dataImporterSessionFields } from '../../forms/ImporterForms'; import { - useApiFormModal, useCreateApiFormModal, useDeleteApiFormModal, useEditApiFormModal @@ -105,17 +105,26 @@ export function BomTable({ return ( part && ( - - } - extra={extra} - title={t`Part Information`} - /> + + + } + extra={extra} + title={t`Part Information`} + /> + {!record.validated && ( + + + + + + )} + ) ); } @@ -499,26 +508,6 @@ export function BomTable({ } }); - const validateBom = useApiFormModal({ - url: ApiEndpoints.bom_validate, - method: 'PUT', - fields: { - valid: { - hidden: true, - value: true - } - }, - title: t`Validate BOM`, - pk: partId, - preFormContent: ( - } title={t`Validate BOM`}> - {t`Do you want to validate the bill of materials for this assembly?`} - - ), - successMessage: t`BOM validated`, - onFormSuccess: () => table.refreshTable() - }); - const validateBomItem = useCallback((record: any) => { const url = apiUrl(ApiEndpoints.bom_item_validate, record.pk); @@ -608,13 +597,6 @@ export function BomTable({ icon={} onClick={() => importBomItem.open()} />, -