[Refactor] BOM Validation (#10056)

* Add "bom_validated" field to the Part model

* Check bom validity of any assemblies when a part is changed

* Improved update logic

* Fixes for circular imports

* Add additional info to BOM validation serializer

* More intelligent caching

* Refactor

* Update API filter

* Data migration to process existing BomItem entries

* Add "BOM Valid" filter to part table

* Add dashboard widget

* Display BOM validation status

* Tweak dashboard widget

* Update BomTable

* Allow locked BOM items to be validated

* Adjust get_item_hash

- preserve "some" backwards compatibility

* Bump API version

* Refactor app URL patterns

* Fix import sequence

* Tweak imports

* Fix logging message

* Fix error message

* Update src/backend/InvenTree/part/migrations/0141_auto_20250722_0303.py

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

* Update exception handling

* Try info level debug

* Disable exchange rate update

* Add registry ready flag

* Add is_ready func

* Cleaner init code

* Protect against plugin access until ready

* Fix dashboard widget filter

* Adjust unit test

* Fix receiver name

* Only add plugin URLs if registry is ready

* Cleanup code

* Update playwright tests

* Update docs

* Revert changes to urls.py

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Oliver
2025-07-23 20:16:00 +10:00
committed by GitHub
parent 20477fbfcc
commit dfd9fe44a4
18 changed files with 641 additions and 168 deletions

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@ -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:

View File

@ -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

View File

@ -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):

View File

@ -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",
),
),
]

View File

@ -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),
]

View File

@ -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):

View File

@ -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."""

View File

@ -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()

View File

@ -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."""

View File

@ -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`,

View File

@ -95,7 +95,7 @@ function QueryCountWidget({
}, [query.isFetching, query.isError, query.data]);
return (
<Anchor href='#' onClick={onFollowLink}>
<Anchor href='#' onClick={onFollowLink} underline='never'>
<Group
gap='xs'
wrap='nowrap'

View File

@ -1,9 +1,13 @@
import { t } from '@lingui/core/macro';
import {
ActionIcon,
Alert,
Center,
Grid,
Group,
HoverCard,
Loader,
type MantineColor,
Skeleton,
Stack,
Text
@ -11,11 +15,14 @@ import {
import {
IconBookmarks,
IconBuilding,
IconCircleCheck,
IconClipboardList,
IconCurrencyDollar,
IconExclamationCircle,
IconInfoCircle,
IconLayersLinked,
IconList,
IconListCheck,
IconListTree,
IconLock,
IconPackages,
@ -38,6 +45,7 @@ import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl } from '@lib/functions/Navigation';
import { ActionButton } from '@lib/index';
import type { ApiFormFieldSet } from '@lib/types/Forms';
import AdminButton from '../../components/buttons/AdminButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
@ -66,6 +74,7 @@ import NotesPanel from '../../components/panels/NotesPanel';
import type { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import { RenderPart } from '../../components/render/Part';
import { RenderUser } from '../../components/render/User';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
import { useApi } from '../../contexts/ApiContext';
import { formatDecimal, formatPriceRange } from '../../defaults/formatters';
@ -75,6 +84,7 @@ import {
useFindSerialNumberForm
} from '../../forms/StockForms';
import {
useApiFormModal,
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
@ -179,6 +189,14 @@ export default function PartDetail() {
refetchOnMount: true
});
const { instance: bomInformation, instanceQuery: bomInformationQuery } =
useInstance({
endpoint: ApiEndpoints.bom_validate,
pk: id,
hasPrimaryKey: true,
refetchOnMount: true
});
const { instance: partRequirements, instanceQuery: partRequirementsQuery } =
useInstance({
endpoint: ApiEndpoints.part_requirements,
@ -657,6 +675,101 @@ export default function PartDetail() {
partRequirements
]);
const validateBom = useApiFormModal({
url: ApiEndpoints.bom_validate,
method: 'PUT',
fields: {
valid: {
hidden: true,
value: true
}
},
title: t`Validate BOM`,
pk: id,
preFormContent: (
<Alert color='green' icon={<IconCircleCheck />} title={t`Validate BOM`}>
<Text>{t`Do you want to validate the bill of materials for this assembly?`}</Text>
</Alert>
),
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 <Loader size='sm' />;
}
let icon: ReactNode;
let color: MantineColor;
let title = '';
let description = '';
if (bomInformation?.bom_validated) {
color = 'green';
icon = <IconListCheck />;
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 = <IconExclamationCircle />;
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 = <IconExclamationCircle />;
title = t`BOM Not Validated`;
description = t`The Bill of Materials for this part has not yet been validated`;
}
return (
<Group gap='xs' justify='flex-end'>
{!bomInformation.bom_validated && (
<ActionButton
icon={<IconCircleCheck />}
color='green'
tooltip={t`Validate BOM`}
onClick={validateBom.open}
/>
)}
<HoverCard position='bottom-end'>
<HoverCard.Target>
<ActionIcon
color={color}
variant='transparent'
aria-label='bom-validation-info'
>
{icon}
</ActionIcon>
</HoverCard.Target>
<HoverCard.Dropdown>
<Alert color={color} icon={icon} title={title}>
<Stack gap='xs'>
<Text size='sm'>{description}</Text>
{bomInformation?.bom_checked_date && (
<Text size='sm'>
{t`Validated On`}: {bomInformation.bom_checked_date}
</Text>
)}
{bomInformation?.bom_checked_by_detail && (
<Group gap='xs'>
<Text size='sm'>{t`Validated By`}: </Text>
<RenderUser
instance={bomInformation.bom_checked_by_detail}
/>
</Group>
)}
</Stack>
</Alert>
</HoverCard.Dropdown>
</HoverCard>
</Group>
);
}, [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: <IconListTree />,
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}

View File

@ -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 && (
<TableHoverCard
value={
<Thumbnail
src={part.thumbnail || part.image}
alt={part.description}
text={part.full_name}
/>
}
extra={extra}
title={t`Part Information`}
/>
<Group gap='xs' justify='space-between' wrap='nowrap'>
<TableHoverCard
value={
<Thumbnail
src={part.thumbnail || part.image}
alt={part.description}
text={part.full_name}
/>
}
extra={extra}
title={t`Part Information`}
/>
{!record.validated && (
<Tooltip label={t`This BOM item has not been validated`}>
<ActionIcon color='red' variant='transparent' size='sm'>
<IconExclamationCircle />
</ActionIcon>
</Tooltip>
)}
</Group>
)
);
}
@ -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: (
<Alert color='green' icon={<IconCircleCheck />} title={t`Validate BOM`}>
<Text>{t`Do you want to validate the bill of materials for this assembly?`}</Text>
</Alert>
),
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={<IconFileArrowLeft />}
onClick={() => importBomItem.open()}
/>,
<ActionButton
key='validate-bom'
hidden={partLocked || !user.hasChangeRole(UserRoles.part)}
tooltip={t`Validate BOM`}
icon={<IconCircleCheck />}
onClick={() => validateBom.open()}
/>,
<AddItemButton
key='add-bom-item'
hidden={partLocked || !user.hasAddRole(UserRoles.part)}
@ -629,7 +611,6 @@ export function BomTable({
{importBomItem.modal}
{newBomItem.modal}
{editBomItem.modal}
{validateBom.modal}
{deleteBomItem.modal}
{editSubstitues.modal}
<Stack gap='xs'>

View File

@ -201,6 +201,12 @@ function partTableFilters(): TableFilter[] {
description: t`Filter by assembly attribute`,
type: 'boolean'
},
{
name: 'bom_valid',
label: t`BOM Valid`,
description: t`Filter by parts with a valid BOM`,
type: 'boolean'
},
{
name: 'cascade',
label: t`Include Subcategories`,

View File

@ -4,7 +4,8 @@ import {
clickOnRowMenu,
getRowFromCell,
loadTab,
navigate
navigate,
setTableChoiceFilter
} from '../helpers';
import { doCachedLogin } from '../login';
@ -81,10 +82,32 @@ test('Parts - Supplier Parts', async ({ browser }) => {
});
test('Parts - BOM', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'part/87/bom' });
const page = await doCachedLogin(browser, {
url: 'part/category/index/parts'
});
// Display all active assemblies with validated BOMs
await clearTableFilters(page);
await setTableChoiceFilter(page, 'assembly', 'Yes');
await setTableChoiceFilter(page, 'active', 'Yes');
await setTableChoiceFilter(page, 'BOM Valid', 'Yes');
await page.getByText('1 - 12 / 12').waitFor();
// Navigate to BOM for a particular assembly
await navigate(page, 'part/87/bom');
await loadTab(page, 'Bill of Materials');
await page.waitForLoadState('networkidle');
// Mouse-hover to display BOM validation info for this assembly
await page.getByRole('button', { name: 'bom-validation-info' }).hover();
await page
.getByText('The Bill of Materials for this part has been validated')
.waitFor();
await page.getByText('Validated On: 2025-07-23').waitFor();
await page.getByText('Robert Shuruncle').waitFor();
// Move the mouse away
await page.getByRole('link', { name: 'Bill of Materials' }).hover();
const cell = await page.getByRole('cell', {
name: 'Small plastic enclosure, black',