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

[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

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