mirror of
https://github.com/inventree/InvenTree
synced 2025-07-26 05:15:32 +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:
2
.github/workflows/qc_checks.yaml
vendored
2
.github/workflows/qc_checks.yaml
vendored
@ -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
|
||||
|
BIN
docs/docs/assets/images/build/bom_invalid.png
Normal file
BIN
docs/docs/assets/images/build/bom_invalid.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 46 KiB |
BIN
docs/docs/assets/images/build/bom_validated.png
Normal file
BIN
docs/docs/assets/images/build/bom_validated.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 75 KiB |
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
@ -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),
|
||||
]
|
@ -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):
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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()
|
||||
|
@ -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."""
|
||||
|
@ -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`,
|
||||
|
@ -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'
|
||||
|
@ -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}
|
||||
|
@ -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'>
|
||||
|
@ -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`,
|
||||
|
@ -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',
|
||||
|
Reference in New Issue
Block a user