mirror of
https://github.com/inventree/InvenTree.git
synced 2025-08-06 03:51:34 +00:00
BOM Enhancements (#10042)
* Add "round_up_multiple" field * Adjust field def * Add serializer field * Update frontend * Nullify empty numerical values * Calculate round_up_multiple value * Adjust table rendering * Update API version * Add unit test * Additional unit test * Change name of value * Update BOM docs * Add new fields * Add data migration for new fields * Bug fix for data migration * Adjust API fields * Bump API docs * Update frontend * Remove old 'overage' field * Updated BOM docs * Docs tweak * Fix required quantity calculation * additional unit tests * Tweak BOM table * Enhanced "can_build" serializer * Refactor "can_build" calculations * Code cleanup * Serializer fix * Enhanced rendering * Updated playwright tests * Fix method name * Update API unit test * Refactor 'can_build' calculation - Make it much more efficient - Reduce code duplication * Fix unit test * Adjust serializer type * Update src/backend/InvenTree/part/models.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/backend/InvenTree/part/test_bom_item.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update docs/docs/manufacturing/bom.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update docs/docs/manufacturing/bom.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Adjust unit test * Adjust tests * Tweak requirements * Tweak playwright tests * More playwright fixes --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,12 +1,17 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 370
|
||||
INVENTREE_API_VERSION = 371
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v371 -> 2025-07-18 : https://github.com/inventree/InvenTree/pull/10042
|
||||
- Adds "setup_quantity" and "attrition" fields to BomItem API endpoints
|
||||
- Remove "overage" field from BomItem API endpoints
|
||||
- Adds "rounding_multiple" field to BomItem API endpoints
|
||||
|
||||
v370 -> 2025-07-17 : https://github.com/inventree/InvenTree/pull/10036
|
||||
- Adds optional "assembly_detail" information to BuildLine API endpoint
|
||||
- Adds "include_variants" filter to SalesOrderLineItem API endpoint
|
||||
|
@@ -40,7 +40,6 @@ from stock.models import StockItem, StockLocation
|
||||
|
||||
from . import config, helpers, ready, schema, status, version
|
||||
from .tasks import offload_task
|
||||
from .validators import validate_overage
|
||||
|
||||
|
||||
class TreeFixtureTest(TestCase):
|
||||
@@ -463,27 +462,6 @@ class ConversionTest(TestCase):
|
||||
class ValidatorTest(TestCase):
|
||||
"""Simple tests for custom field validators."""
|
||||
|
||||
def test_overage(self):
|
||||
"""Test overage validator."""
|
||||
validate_overage('100%')
|
||||
validate_overage('10')
|
||||
validate_overage('45.2 %')
|
||||
|
||||
with self.assertRaises(django_exceptions.ValidationError):
|
||||
validate_overage('-1')
|
||||
|
||||
with self.assertRaises(django_exceptions.ValidationError):
|
||||
validate_overage('-2.04 %')
|
||||
|
||||
with self.assertRaises(django_exceptions.ValidationError):
|
||||
validate_overage('105%')
|
||||
|
||||
with self.assertRaises(django_exceptions.ValidationError):
|
||||
validate_overage('xxx %')
|
||||
|
||||
with self.assertRaises(django_exceptions.ValidationError):
|
||||
validate_overage('aaaa')
|
||||
|
||||
def test_url_validation(self):
|
||||
"""Test for AllowedURLValidator."""
|
||||
from common.models import InvenTreeSetting
|
||||
|
@@ -1,7 +1,5 @@
|
||||
"""Custom field validators for InvenTree."""
|
||||
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from django.conf import settings
|
||||
from django.core import validators
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -95,46 +93,3 @@ def validate_sales_order_reference(value):
|
||||
|
||||
def validate_tree_name(value):
|
||||
"""Placeholder for legacy function used in migrations."""
|
||||
|
||||
|
||||
def validate_overage(value):
|
||||
"""Validate that a BOM overage string is properly formatted.
|
||||
|
||||
An overage string can look like:
|
||||
|
||||
- An integer number ('1' / 3 / 4)
|
||||
- A decimal number ('0.123')
|
||||
- A percentage ('5%' / '10 %')
|
||||
"""
|
||||
value = str(value).lower().strip()
|
||||
|
||||
# First look for a simple numerical value
|
||||
try:
|
||||
i = Decimal(value)
|
||||
|
||||
if i < 0:
|
||||
raise ValidationError(_('Overage value must not be negative'))
|
||||
|
||||
# Looks like a number
|
||||
return
|
||||
except (ValueError, InvalidOperation):
|
||||
pass
|
||||
|
||||
# Now look for a percentage value
|
||||
if value.endswith('%'):
|
||||
v = value[:-1].strip()
|
||||
|
||||
# Does it look like a number?
|
||||
try:
|
||||
f = float(v)
|
||||
|
||||
if f < 0:
|
||||
raise ValidationError(_('Overage value must not be negative'))
|
||||
elif f > 100:
|
||||
raise ValidationError(_('Overage must not exceed 100%'))
|
||||
|
||||
return
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
raise ValidationError(_('Invalid value for overage'))
|
||||
|
@@ -1558,7 +1558,7 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo
|
||||
|
||||
When a new Build is created, the BuildLine objects are created automatically.
|
||||
- A BuildLine entry is created for each BOM item associated with the part
|
||||
- The quantity is set to the quantity required to build the part (including overage)
|
||||
- The quantity is set to the quantity required to build the part
|
||||
- BuildItem objects are associated with a particular BuildLine
|
||||
|
||||
Once a build has been created, BuildLines can (optionally) be removed from the Build
|
||||
|
@@ -1409,6 +1409,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
||||
substitutes=False,
|
||||
sub_part_detail=False,
|
||||
part_detail=False,
|
||||
can_build=False,
|
||||
)
|
||||
|
||||
assembly_detail = part_serializers.PartBriefSerializer(
|
||||
|
@@ -1681,14 +1681,12 @@ class BomMixin:
|
||||
"""
|
||||
# Do we wish to include extra detail?
|
||||
try:
|
||||
kwargs['part_detail'] = str2bool(self.request.GET.get('part_detail', None))
|
||||
except AttributeError:
|
||||
pass
|
||||
params = self.request.query_params
|
||||
|
||||
kwargs['can_build'] = str2bool(params.get('can_build', True))
|
||||
kwargs['part_detail'] = str2bool(params.get('part_detail', False))
|
||||
kwargs['sub_part_detail'] = str2bool(params.get('sub_part_detail', False))
|
||||
|
||||
try:
|
||||
kwargs['sub_part_detail'] = str2bool(
|
||||
self.request.GET.get('sub_part_detail', None)
|
||||
)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
@@ -1729,6 +1727,9 @@ class BomList(BomMixin, DataExportViewMixin, ListCreateDestroyAPIView):
|
||||
ordering_fields = [
|
||||
'can_build',
|
||||
'quantity',
|
||||
'setup_quantity',
|
||||
'attrition',
|
||||
'rounding_multiple',
|
||||
'sub_part',
|
||||
'available_stock',
|
||||
'allow_variants',
|
||||
|
@@ -30,6 +30,7 @@ from django.db.models import (
|
||||
When,
|
||||
)
|
||||
from django.db.models.functions import Cast, Coalesce, Greatest
|
||||
from django.db.models.query import QuerySet
|
||||
|
||||
from sql_util.utils import SubquerySum
|
||||
|
||||
@@ -361,6 +362,144 @@ def annotate_sub_categories():
|
||||
)
|
||||
|
||||
|
||||
def annotate_bom_item_can_build(queryset: QuerySet, reference: str = '') -> QuerySet:
|
||||
"""Annotate the 'can_build' quantity for each BomItem in a queryset.
|
||||
|
||||
Arguments:
|
||||
queryset: A queryset of BomItem objects
|
||||
reference: Reference to the BomItem from the current queryset (default = '')
|
||||
|
||||
To do this we need to also annotate some other fields which are used in the calculation:
|
||||
|
||||
- total_in_stock: Total stock quantity for the part (may include variant stock)
|
||||
- available_stock: Total available stock quantity for the part
|
||||
- variant_stock: Total stock quantity for any variant parts
|
||||
- substitute_stock: Total stock quantity for any substitute parts
|
||||
|
||||
And then finally, annotate the 'can_build' quantity for each BomItem:
|
||||
"""
|
||||
# Pre-fetch the required related fields
|
||||
queryset = queryset.prefetch_related(
|
||||
f'{reference}sub_part',
|
||||
f'{reference}sub_part__stock_items',
|
||||
f'{reference}sub_part__stock_items__allocations',
|
||||
f'{reference}sub_part__stock_items__sales_order_allocations',
|
||||
f'{reference}substitutes',
|
||||
f'{reference}substitutes__part__stock_items',
|
||||
)
|
||||
|
||||
# Queryset reference to the linked sub_part instance
|
||||
sub_part_ref = f'{reference}sub_part__'
|
||||
|
||||
# Apply some aliased annotations to the queryset
|
||||
queryset = queryset.annotate(
|
||||
# Total stock quantity (just for the sub_part itself)
|
||||
total_stock=annotate_total_stock(sub_part_ref),
|
||||
# Total allocated to sales orders
|
||||
allocated_to_sales_orders=annotate_sales_order_allocations(sub_part_ref),
|
||||
# Total allocated to build orders
|
||||
allocated_to_build_orders=annotate_build_order_allocations(sub_part_ref),
|
||||
)
|
||||
|
||||
# Annotate the "available" stock, based on the total stock and allocations
|
||||
queryset = queryset.annotate(
|
||||
available_stock=Greatest(
|
||||
ExpressionWrapper(
|
||||
F('total_stock')
|
||||
- F('allocated_to_sales_orders')
|
||||
- F('allocated_to_build_orders'),
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
)
|
||||
|
||||
# Annotate the total stock for any variant parts
|
||||
vq = variant_stock_query(reference=sub_part_ref)
|
||||
|
||||
queryset = queryset.alias(
|
||||
variant_stock_total=annotate_variant_quantity(vq, reference='quantity'),
|
||||
variant_bo_allocations=annotate_variant_quantity(
|
||||
vq, reference='sales_order_allocations__quantity'
|
||||
),
|
||||
variant_so_allocations=annotate_variant_quantity(
|
||||
vq, reference='allocations__quantity'
|
||||
),
|
||||
)
|
||||
|
||||
# Annotate total variant stock
|
||||
queryset = queryset.annotate(
|
||||
available_variant_stock=Greatest(
|
||||
ExpressionWrapper(
|
||||
F('variant_stock_total')
|
||||
- F('variant_bo_allocations')
|
||||
- F('variant_so_allocations'),
|
||||
output_field=FloatField(),
|
||||
),
|
||||
0,
|
||||
output_field=FloatField(),
|
||||
)
|
||||
)
|
||||
|
||||
# Account for substitute parts
|
||||
substitute_ref = f'{reference}substitutes__part__'
|
||||
|
||||
# Extract similar information for any 'substitute' parts
|
||||
queryset = queryset.alias(
|
||||
substitute_stock=annotate_total_stock(reference=substitute_ref),
|
||||
substitute_build_allocations=annotate_build_order_allocations(
|
||||
reference=substitute_ref
|
||||
),
|
||||
substitute_sales_allocations=annotate_sales_order_allocations(
|
||||
reference=substitute_ref
|
||||
),
|
||||
)
|
||||
|
||||
# Calculate 'available_substitute_stock' field
|
||||
queryset = queryset.annotate(
|
||||
available_substitute_stock=Greatest(
|
||||
ExpressionWrapper(
|
||||
F('substitute_stock')
|
||||
- F('substitute_build_allocations')
|
||||
- F('substitute_sales_allocations'),
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
)
|
||||
|
||||
# Now we can annotate the total "available" stock for the BomItem
|
||||
queryset = queryset.alias(
|
||||
total_stock=ExpressionWrapper(
|
||||
F('available_variant_stock')
|
||||
+ F('available_substitute_stock')
|
||||
+ F('available_stock'),
|
||||
output_field=FloatField(),
|
||||
)
|
||||
)
|
||||
|
||||
# And finally, we can annotate the 'can_build' quantity for each BomItem
|
||||
queryset = queryset.annotate(
|
||||
can_build=Greatest(
|
||||
ExpressionWrapper(
|
||||
Case(
|
||||
When(Q(quantity=0), then=Value(0)),
|
||||
default=(F('total_stock') - F('setup_quantity'))
|
||||
/ (F('quantity') * (1.0 + F('attrition') / 100.0)),
|
||||
output_field=FloatField(),
|
||||
),
|
||||
output_field=FloatField(),
|
||||
),
|
||||
Decimal(0),
|
||||
output_field=FloatField(),
|
||||
)
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
"""A list of valid operators for filtering part parameters."""
|
||||
PARAMETER_FILTER_OPERATORS: list[str] = ['gt', 'gte', 'lt', 'lte', 'ne', 'icontains']
|
||||
|
||||
|
@@ -38,7 +38,7 @@ class Migration(migrations.Migration):
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('quantity', models.PositiveIntegerField(default=1, help_text='BOM quantity for this BOM item', validators=[django.core.validators.MinValueValidator(0)])),
|
||||
('overage', models.CharField(blank=True, help_text='Estimated build wastage quantity (absolute or percentage)', max_length=24, validators=[InvenTree.validators.validate_overage])),
|
||||
('overage', models.CharField(blank=True, help_text='Estimated build wastage quantity (absolute or percentage)', max_length=24, validators=[])),
|
||||
('note', models.CharField(blank=True, help_text='BOM item notes', max_length=100)),
|
||||
],
|
||||
options={
|
||||
|
@@ -38,7 +38,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='bomitem',
|
||||
name='overage',
|
||||
field=models.CharField(blank=True, help_text='Estimated build wastage quantity (absolute or percentage)', max_length=24, validators=[InvenTree.validators.validate_overage], verbose_name='Overage'),
|
||||
field=models.CharField(blank=True, help_text='Estimated build wastage quantity (absolute or percentage)', max_length=24, validators=[], verbose_name='Overage'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='bomitem',
|
||||
|
@@ -0,0 +1,55 @@
|
||||
# Generated by Django 4.2.23 on 2025-07-18 23:40
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("part", "0136_partparameter_note_partparameter_updated_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="bomitem",
|
||||
name="attrition",
|
||||
field=models.DecimalField(
|
||||
decimal_places=3,
|
||||
default=0,
|
||||
help_text="Estimated attrition for a build, expressed as a percentage (0-100)",
|
||||
max_digits=6,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(0),
|
||||
django.core.validators.MaxValueValidator(100),
|
||||
],
|
||||
verbose_name="Attrition",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="bomitem",
|
||||
name="rounding_multiple",
|
||||
field=models.DecimalField(
|
||||
blank=True,
|
||||
decimal_places=5,
|
||||
default=None,
|
||||
help_text="Round up required production quantity to nearest multiple of this value",
|
||||
max_digits=15,
|
||||
null=True,
|
||||
validators=[django.core.validators.MinValueValidator(0)],
|
||||
verbose_name="Rounding Multiple",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="bomitem",
|
||||
name="setup_quantity",
|
||||
field=models.DecimalField(
|
||||
decimal_places=5,
|
||||
default=0,
|
||||
help_text="Extra required quantity for a build, to account for setup losses",
|
||||
max_digits=15,
|
||||
validators=[django.core.validators.MinValueValidator(0)],
|
||||
verbose_name="Setup Quantity",
|
||||
),
|
||||
),
|
||||
]
|
@@ -0,0 +1,88 @@
|
||||
# Generated by Django 4.2.23 on 2025-07-18 23:42
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def convert_overage(apps, schema_editor):
|
||||
"""Convert 'overage' field to 'setup_quantity' and 'attrition' fields.
|
||||
|
||||
- The 'overage' field is split into two new fields:
|
||||
- 'setup_quantity': The integer part of the overage.
|
||||
- 'attrition': The fractional part of the overage.
|
||||
"""
|
||||
|
||||
BomItem = apps.get_model('part', 'BomItem')
|
||||
|
||||
# Fetch all BomItem objects with a non-zero overage
|
||||
bom_items = BomItem.objects.exclude(overage='').exclude(overage=None).distinct()
|
||||
|
||||
if bom_items.count() == 0:
|
||||
return
|
||||
|
||||
print(f"\nConverting {bom_items.count()} BomItem objects with 'overage' to 'setup_quantity' and 'attrition'")
|
||||
|
||||
for item in bom_items:
|
||||
|
||||
# First attempt - convert to a percentage
|
||||
overage = str(item.overage).strip()
|
||||
|
||||
if overage.endswith('%'):
|
||||
try:
|
||||
attrition = Decimal(overage[:-1])
|
||||
attrition = max(0, attrition) # Ensure it's not negative
|
||||
attrition = min(100, attrition) # Cap at 100%
|
||||
|
||||
item.attrition = attrition
|
||||
item.setup_quantity = Decimal(0)
|
||||
item.save()
|
||||
except Exception as e:
|
||||
print(f" - Error converting {item.pk} from percentage: {e}")
|
||||
continue
|
||||
|
||||
else:
|
||||
# If not a percentage, treat it as a decimal number
|
||||
try:
|
||||
setup_quantity = Decimal(overage)
|
||||
setup_quantity = max(0, setup_quantity) # Ensure it's not negative
|
||||
|
||||
item.setup_quantity = setup_quantity
|
||||
item.attrition = Decimal(0)
|
||||
item.save()
|
||||
except Exception as e:
|
||||
print(f"- Error converting {item.pk} from decimal: {e}")
|
||||
continue
|
||||
|
||||
|
||||
def revert_overage(apps, schema_editor):
|
||||
"""Revert the 'setup_quantity' and 'attrition' fields back to 'overage'.
|
||||
|
||||
- Combines 'setup_quantity' and 'attrition' back into the 'overage' field.
|
||||
"""
|
||||
|
||||
BomItem = apps.get_model('part', 'BomItem')
|
||||
|
||||
# First, convert all 'attrition' values to percentages
|
||||
for item in BomItem.objects.exclude(attrition=0).distinct():
|
||||
item.overage = f"{item.attrition}%"
|
||||
item.save()
|
||||
|
||||
# Second, convert all 'setup_quantity' values to strings
|
||||
for item in BomItem.objects.exclude(setup_quantity=0).distinct():
|
||||
item.overage = str(item.setup_quantity or 0)
|
||||
item.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("part", "0137_bomitem_attrition_bomitem_rounding_multiple_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
code=convert_overage,
|
||||
reverse_code=revert_overage,
|
||||
)
|
||||
]
|
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 4.2.23 on 2025-07-19 00:22
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("part", "0138_auto_20250718_2342"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="bomitem",
|
||||
name="overage",
|
||||
),
|
||||
]
|
@@ -15,10 +15,14 @@ from typing import Optional, cast
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinLengthValidator, MinValueValidator
|
||||
from django.core.validators import (
|
||||
MaxValueValidator,
|
||||
MinLengthValidator,
|
||||
MinValueValidator,
|
||||
)
|
||||
from django.db import models, transaction
|
||||
from django.db.models import ExpressionWrapper, F, Q, QuerySet, Sum, UniqueConstraint
|
||||
from django.db.models.functions import Coalesce, Greatest
|
||||
from django.db.models import F, Q, QuerySet, Sum, UniqueConstraint
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.db.utils import IntegrityError
|
||||
from django.dispatch import receiver
|
||||
@@ -908,7 +912,7 @@ class Part(
|
||||
|
||||
# Generate a query for any stock items for this part variant tree with non-empty serial numbers
|
||||
if not get_global_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', False):
|
||||
# Serial numbers are unique acros part trees
|
||||
# Serial numbers are unique across part trees
|
||||
stock = stock.filter(part__tree_id=self.tree_id)
|
||||
|
||||
# There are no matching StockItem objects (skip further tests)
|
||||
@@ -1558,115 +1562,28 @@ class Part(
|
||||
if not self.has_bom:
|
||||
return 0
|
||||
|
||||
total = None
|
||||
|
||||
# Prefetch related tables, to reduce query expense
|
||||
queryset = self.get_bom_items()
|
||||
|
||||
# Ignore 'consumable' BOM items for this calculation
|
||||
queryset = queryset.filter(consumable=False)
|
||||
|
||||
queryset = queryset.prefetch_related(
|
||||
'sub_part__stock_items',
|
||||
'sub_part__stock_items__allocations',
|
||||
'sub_part__stock_items__sales_order_allocations',
|
||||
'substitutes',
|
||||
'substitutes__part__stock_items',
|
||||
)
|
||||
# Annotate the queryset with the 'can_build' quantity
|
||||
queryset = part.filters.annotate_bom_item_can_build(queryset)
|
||||
|
||||
# Annotate the 'available stock' for each part in the BOM
|
||||
ref = 'sub_part__'
|
||||
queryset = queryset.alias(
|
||||
total_stock=part.filters.annotate_total_stock(reference=ref),
|
||||
so_allocations=part.filters.annotate_sales_order_allocations(reference=ref),
|
||||
bo_allocations=part.filters.annotate_build_order_allocations(reference=ref),
|
||||
)
|
||||
can_build_quantity = None
|
||||
|
||||
# Calculate the 'available stock' based on previous annotations
|
||||
queryset = queryset.annotate(
|
||||
available_stock=Greatest(
|
||||
ExpressionWrapper(
|
||||
F('total_stock') - F('so_allocations') - F('bo_allocations'),
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
0,
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
)
|
||||
for value in queryset.values_list('can_build', flat=True):
|
||||
if can_build_quantity is None:
|
||||
can_build_quantity = value
|
||||
else:
|
||||
can_build_quantity = min(can_build_quantity, value)
|
||||
|
||||
# Extract similar information for any 'substitute' parts
|
||||
ref = 'substitutes__part__'
|
||||
queryset = queryset.alias(
|
||||
sub_total_stock=part.filters.annotate_total_stock(reference=ref),
|
||||
sub_so_allocations=part.filters.annotate_sales_order_allocations(
|
||||
reference=ref
|
||||
),
|
||||
sub_bo_allocations=part.filters.annotate_build_order_allocations(
|
||||
reference=ref
|
||||
),
|
||||
)
|
||||
if can_build_quantity is None:
|
||||
# No BOM items, or no items which can be built
|
||||
return 0
|
||||
|
||||
queryset = queryset.annotate(
|
||||
substitute_stock=Greatest(
|
||||
ExpressionWrapper(
|
||||
F('sub_total_stock')
|
||||
- F('sub_so_allocations')
|
||||
- F('sub_bo_allocations'),
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
0,
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
)
|
||||
|
||||
# Extract similar information for any 'variant' parts
|
||||
variant_stock_query = part.filters.variant_stock_query(reference='sub_part__')
|
||||
|
||||
queryset = queryset.alias(
|
||||
var_total_stock=part.filters.annotate_variant_quantity(
|
||||
variant_stock_query, reference='quantity'
|
||||
),
|
||||
var_bo_allocations=part.filters.annotate_variant_quantity(
|
||||
variant_stock_query, reference='allocations__quantity'
|
||||
),
|
||||
var_so_allocations=part.filters.annotate_variant_quantity(
|
||||
variant_stock_query, reference='sales_order_allocations__quantity'
|
||||
),
|
||||
)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
variant_stock=Greatest(
|
||||
ExpressionWrapper(
|
||||
F('var_total_stock')
|
||||
- F('var_bo_allocations')
|
||||
- F('var_so_allocations'),
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
0,
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
)
|
||||
|
||||
for item in queryset.all():
|
||||
if item.quantity <= 0:
|
||||
# Ignore zero-quantity items
|
||||
continue
|
||||
|
||||
# Iterate through each item in the queryset, work out the limiting quantity
|
||||
quantity = item.available_stock + item.substitute_stock
|
||||
|
||||
if item.allow_variants:
|
||||
quantity += item.variant_stock
|
||||
|
||||
n = int(quantity / item.quantity)
|
||||
|
||||
if total is None or n < total:
|
||||
total = n
|
||||
|
||||
if total is None:
|
||||
total = 0
|
||||
|
||||
return max(total, 0)
|
||||
return int(max(can_build_quantity, 0))
|
||||
|
||||
@property
|
||||
def active_builds(self):
|
||||
@@ -1923,7 +1840,7 @@ class Part(
|
||||
):
|
||||
"""Return a BomItem queryset which returns all BomItem instances which refer to *this* part.
|
||||
|
||||
As the BOM allocation logic is somewhat complicted, there are some considerations:
|
||||
As the BOM allocation logic is somewhat complicated, there are some considerations:
|
||||
|
||||
A) This part may be directly specified in a BomItem instance
|
||||
B) This part may be a *variant* of a part which is directly specified in a BomItem instance
|
||||
@@ -4330,7 +4247,9 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
|
||||
optional: Boolean field describing if this BomItem is optional
|
||||
consumable: Boolean field describing if this BomItem is considered a 'consumable'
|
||||
reference: BOM reference field (e.g. part designators)
|
||||
overage: Estimated losses for a Build. Can be expressed as absolute value (e.g. '7') or a percentage (e.g. '2%')
|
||||
setup_quantity: Extra required quantity for a build, to account for setup losses
|
||||
attrition: Estimated losses for a Build, expressed as a percentage (e.g. '2%')
|
||||
rounding_multiple: Rounding quantity when calculating the required quantity for a build
|
||||
note: Note field for this BOM item
|
||||
checksum: Validation checksum for the particular BOM line item
|
||||
inherited: This BomItem can be inherited by the BOMs of variant parts
|
||||
@@ -4498,12 +4417,37 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
|
||||
help_text=_('This BOM item is consumable (it is not tracked in build orders)'),
|
||||
)
|
||||
|
||||
overage = models.CharField(
|
||||
max_length=24,
|
||||
setup_quantity = models.DecimalField(
|
||||
default=0,
|
||||
max_digits=15,
|
||||
decimal_places=5,
|
||||
validators=[MinValueValidator(0)],
|
||||
verbose_name=_('Setup Quantity'),
|
||||
help_text=_('Extra required quantity for a build, to account for setup losses'),
|
||||
)
|
||||
|
||||
attrition = models.DecimalField(
|
||||
default=0,
|
||||
max_digits=6,
|
||||
decimal_places=3,
|
||||
validators=[MinValueValidator(0), MaxValueValidator(100)],
|
||||
verbose_name=_('Attrition'),
|
||||
help_text=_(
|
||||
'Estimated attrition for a build, expressed as a percentage (0-100)'
|
||||
),
|
||||
)
|
||||
|
||||
rounding_multiple = models.DecimalField(
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[validators.validate_overage],
|
||||
verbose_name=_('Overage'),
|
||||
help_text=_('Estimated build wastage quantity (absolute or percentage)'),
|
||||
default=None,
|
||||
max_digits=15,
|
||||
decimal_places=5,
|
||||
validators=[MinValueValidator(0)],
|
||||
verbose_name=_('Rounding Multiple'),
|
||||
help_text=_(
|
||||
'Round up required production quantity to nearest multiple of this value'
|
||||
),
|
||||
)
|
||||
|
||||
reference = models.CharField(
|
||||
@@ -4567,6 +4511,9 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
|
||||
self.part.pk,
|
||||
self.sub_part.pk,
|
||||
normalize(self.quantity),
|
||||
self.setup_quantity,
|
||||
self.attrition,
|
||||
self.rounding_multiple,
|
||||
self.reference,
|
||||
self.optional,
|
||||
self.inherited,
|
||||
@@ -4642,60 +4589,66 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
|
||||
except Part.DoesNotExist:
|
||||
raise ValidationError({'sub_part': _('Sub part must be specified')})
|
||||
|
||||
def get_overage_quantity(self, quantity):
|
||||
"""Calculate overage quantity."""
|
||||
# Most of the time overage string will be empty
|
||||
if len(self.overage) == 0:
|
||||
return 0
|
||||
def can_build_quantity(self, available_stock: float) -> int:
|
||||
"""Calculate the number of assemblies that can be built with the available stock.
|
||||
|
||||
overage = str(self.overage).strip()
|
||||
|
||||
# Is the overage a numerical value?
|
||||
try:
|
||||
ovg = float(overage)
|
||||
|
||||
ovg = max(ovg, 0)
|
||||
|
||||
return ovg
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Is the overage a percentage?
|
||||
if overage.endswith('%'):
|
||||
overage = overage[:-1].strip()
|
||||
|
||||
try:
|
||||
percent = float(overage) / 100.0
|
||||
percent = min(percent, 1)
|
||||
percent = max(percent, 0)
|
||||
|
||||
# Must be represented as a decimal
|
||||
percent = Decimal(percent)
|
||||
|
||||
return float(percent * quantity)
|
||||
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Default = No overage
|
||||
return 0
|
||||
|
||||
def get_required_quantity(self, build_quantity):
|
||||
"""Calculate the required part quantity, based on the supplier build_quantity. Includes overage estimate in the returned value.
|
||||
|
||||
Args:
|
||||
build_quantity: Number of parts to build
|
||||
Arguments:
|
||||
available_stock: The amount of stock available for this BOM item
|
||||
|
||||
Returns:
|
||||
Quantity required for this build (including overage)
|
||||
The number of assemblies that can be built with the available stock.
|
||||
Returns 0 if the available stock is insufficient.
|
||||
"""
|
||||
# Account for setup quantity
|
||||
available_stock = Decimal(max(0, available_stock - self.setup_quantity))
|
||||
quantity_decimal = Decimal(self.quantity)
|
||||
attrition_decimal = Decimal(self.attrition) / 100
|
||||
n = quantity_decimal * (1 + attrition_decimal)
|
||||
|
||||
if n <= 0:
|
||||
return 0.0
|
||||
|
||||
return int(available_stock / n)
|
||||
|
||||
def get_required_quantity(self, build_quantity: float) -> float:
|
||||
"""Calculate the required part quantity, based on the supplied build_quantity.
|
||||
|
||||
Arguments:
|
||||
build_quantity: Number of assemblies to build
|
||||
|
||||
Returns:
|
||||
Production quantity required for this component
|
||||
"""
|
||||
# Base quantity requirement
|
||||
base_quantity = self.quantity * build_quantity
|
||||
required = self.quantity * build_quantity
|
||||
|
||||
# Overage requirement
|
||||
overage_quantity = self.get_overage_quantity(base_quantity)
|
||||
# Account for attrition
|
||||
if self.attrition > 0:
|
||||
try:
|
||||
# Convert attrition percentage to decimal
|
||||
attrition = Decimal(self.attrition) / Decimal(100)
|
||||
required *= 1 + attrition
|
||||
except Exception:
|
||||
log_error('bom_item.get_required_quantity')
|
||||
|
||||
required = float(base_quantity) + float(overage_quantity)
|
||||
# Account for setup quantity
|
||||
if self.setup_quantity > 0:
|
||||
try:
|
||||
setup_quantity = Decimal(self.setup_quantity)
|
||||
required += setup_quantity
|
||||
except Exception:
|
||||
log_error('bom_item.get_required_quantity')
|
||||
|
||||
# We now have the total requirement
|
||||
# If a "rounding_multiple" is specified, then round up to the nearest multiple
|
||||
if self.rounding_multiple and self.rounding_multiple > 0:
|
||||
try:
|
||||
round_up = Decimal(self.rounding_multiple)
|
||||
value = Decimal(required)
|
||||
value = math.ceil(value / round_up) * round_up
|
||||
required = float(value)
|
||||
except InvalidOperation:
|
||||
log_error('bom_item.get_required_quantity')
|
||||
|
||||
return required
|
||||
|
||||
@@ -4704,23 +4657,23 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
|
||||
"""Return the price-range for this BOM item."""
|
||||
# get internal price setting
|
||||
use_internal = get_global_setting('PART_BOM_USE_INTERNAL_PRICE', False)
|
||||
prange = self.sub_part.get_price_range(
|
||||
p_range = self.sub_part.get_price_range(
|
||||
self.quantity, internal=use_internal and internal
|
||||
)
|
||||
|
||||
if prange is None:
|
||||
return prange
|
||||
if p_range is None:
|
||||
return p_range
|
||||
|
||||
pmin, pmax = prange
|
||||
p_min, p_max = p_range
|
||||
|
||||
if pmin == pmax:
|
||||
return decimal2money(pmin)
|
||||
if p_min == p_max:
|
||||
return decimal2money(p_min)
|
||||
|
||||
# Convert to better string representation
|
||||
pmin = decimal2money(pmin)
|
||||
pmax = decimal2money(pmax)
|
||||
p_min = decimal2money(p_min)
|
||||
p_max = decimal2money(p_max)
|
||||
|
||||
return f'{pmin} to {pmax}'
|
||||
return f'{p_min} to {p_max}'
|
||||
|
||||
|
||||
@receiver(post_save, sender=BomItem, dispatch_uid='update_bom_build_lines')
|
||||
|
@@ -8,7 +8,7 @@ from django.core.exceptions import ValidationError
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import IntegrityError, models, transaction
|
||||
from django.db.models import ExpressionWrapper, F, FloatField, Q
|
||||
from django.db.models import ExpressionWrapper, F, Q
|
||||
from django.db.models.functions import Coalesce, Greatest
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -836,9 +836,6 @@ class PartSerializer(
|
||||
)
|
||||
)
|
||||
|
||||
# TODO: This could do with some refactoring
|
||||
# TODO: Note that BomItemSerializer and BuildLineSerializer have very similar code
|
||||
|
||||
queryset = queryset.annotate(
|
||||
ordering=part_filters.annotate_on_order_quantity(),
|
||||
in_stock=part_filters.annotate_total_stock(),
|
||||
@@ -1685,11 +1682,13 @@ class BomItemSerializer(
|
||||
'sub_part',
|
||||
'reference',
|
||||
'quantity',
|
||||
'overage',
|
||||
'allow_variants',
|
||||
'inherited',
|
||||
'optional',
|
||||
'consumable',
|
||||
'setup_quantity',
|
||||
'attrition',
|
||||
'rounding_multiple',
|
||||
'note',
|
||||
'pk',
|
||||
'part_detail',
|
||||
@@ -1720,6 +1719,7 @@ class BomItemSerializer(
|
||||
- part_detail and sub_part_detail serializers are only included if requested.
|
||||
- This saves a bunch of database requests
|
||||
"""
|
||||
can_build = kwargs.pop('can_build', True)
|
||||
part_detail = kwargs.pop('part_detail', False)
|
||||
sub_part_detail = kwargs.pop('sub_part_detail', True)
|
||||
pricing = kwargs.pop('pricing', True)
|
||||
@@ -1736,6 +1736,9 @@ class BomItemSerializer(
|
||||
if not sub_part_detail:
|
||||
self.fields.pop('sub_part_detail', None)
|
||||
|
||||
if not can_build:
|
||||
self.fields.pop('can_build')
|
||||
|
||||
if not substitutes:
|
||||
self.fields.pop('substitutes', None)
|
||||
|
||||
@@ -1748,6 +1751,14 @@ class BomItemSerializer(
|
||||
|
||||
quantity = InvenTree.serializers.InvenTreeDecimalField(required=True)
|
||||
|
||||
setup_quantity = InvenTree.serializers.InvenTreeDecimalField(required=False)
|
||||
|
||||
attrition = InvenTree.serializers.InvenTreeDecimalField(required=False)
|
||||
|
||||
rounding_multiple = InvenTree.serializers.InvenTreeDecimalField(
|
||||
required=False, allow_null=True
|
||||
)
|
||||
|
||||
def validate_quantity(self, quantity):
|
||||
"""Perform validation for the BomItem quantity field."""
|
||||
if quantity <= 0:
|
||||
@@ -1877,33 +1888,6 @@ class BomItemSerializer(
|
||||
building=part_filters.annotate_in_production_quantity(ref)
|
||||
)
|
||||
|
||||
# Calculate "total stock" for the referenced sub_part
|
||||
# Calculate the "build_order_allocations" for the sub_part
|
||||
# Note that these fields are only aliased, not annotated
|
||||
queryset = queryset.alias(
|
||||
total_stock=part_filters.annotate_total_stock(reference=ref),
|
||||
allocated_to_sales_orders=part_filters.annotate_sales_order_allocations(
|
||||
reference=ref
|
||||
),
|
||||
allocated_to_build_orders=part_filters.annotate_build_order_allocations(
|
||||
reference=ref
|
||||
),
|
||||
)
|
||||
|
||||
# Calculate 'available_stock' based on previously annotated fields
|
||||
queryset = queryset.annotate(
|
||||
available_stock=Greatest(
|
||||
ExpressionWrapper(
|
||||
F('total_stock')
|
||||
- F('allocated_to_sales_orders')
|
||||
- F('allocated_to_build_orders'),
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
)
|
||||
|
||||
# Calculate 'external_stock'
|
||||
queryset = queryset.annotate(
|
||||
external_stock=part_filters.annotate_total_stock(
|
||||
@@ -1911,74 +1895,8 @@ class BomItemSerializer(
|
||||
)
|
||||
)
|
||||
|
||||
ref = 'substitutes__part__'
|
||||
|
||||
# Extract similar information for any 'substitute' parts
|
||||
queryset = queryset.alias(
|
||||
substitute_stock=part_filters.annotate_total_stock(reference=ref),
|
||||
substitute_build_allocations=part_filters.annotate_build_order_allocations(
|
||||
reference=ref
|
||||
),
|
||||
substitute_sales_allocations=part_filters.annotate_sales_order_allocations(
|
||||
reference=ref
|
||||
),
|
||||
)
|
||||
|
||||
# Calculate 'available_substitute_stock' field
|
||||
queryset = queryset.annotate(
|
||||
available_substitute_stock=Greatest(
|
||||
ExpressionWrapper(
|
||||
F('substitute_stock')
|
||||
- F('substitute_build_allocations')
|
||||
- F('substitute_sales_allocations'),
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
)
|
||||
|
||||
# Annotate the queryset with 'available variant stock' information
|
||||
variant_stock_query = part_filters.variant_stock_query(reference='sub_part__')
|
||||
|
||||
queryset = queryset.alias(
|
||||
variant_stock_total=part_filters.annotate_variant_quantity(
|
||||
variant_stock_query, reference='quantity'
|
||||
),
|
||||
variant_bo_allocations=part_filters.annotate_variant_quantity(
|
||||
variant_stock_query, reference='sales_order_allocations__quantity'
|
||||
),
|
||||
variant_so_allocations=part_filters.annotate_variant_quantity(
|
||||
variant_stock_query, reference='allocations__quantity'
|
||||
),
|
||||
)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
available_variant_stock=Greatest(
|
||||
ExpressionWrapper(
|
||||
F('variant_stock_total')
|
||||
- F('variant_bo_allocations')
|
||||
- F('variant_so_allocations'),
|
||||
output_field=FloatField(),
|
||||
),
|
||||
0,
|
||||
output_field=FloatField(),
|
||||
)
|
||||
)
|
||||
|
||||
# Annotate the "can_build" quantity
|
||||
queryset = queryset.alias(
|
||||
total_stock=ExpressionWrapper(
|
||||
F('available_variant_stock')
|
||||
+ F('available_substitute_stock')
|
||||
+ F('available_stock'),
|
||||
output_field=FloatField(),
|
||||
)
|
||||
).annotate(
|
||||
can_build=ExpressionWrapper(
|
||||
F('total_stock') / F('quantity'), output_field=FloatField()
|
||||
)
|
||||
)
|
||||
# Annotate available stock and "can_build" quantities
|
||||
queryset = part_filters.annotate_bom_item_can_build(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
|
@@ -2480,7 +2480,9 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
'inherited',
|
||||
'note',
|
||||
'optional',
|
||||
'overage',
|
||||
'setup_quantity',
|
||||
'attrition',
|
||||
'rounding_multiple',
|
||||
'pk',
|
||||
'part',
|
||||
'quantity',
|
||||
@@ -2753,6 +2755,49 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
self.assertEqual(str(row['Assembly']), '100')
|
||||
self.assertEqual(str(row['BOM Level']), '1')
|
||||
|
||||
def test_can_build(self):
|
||||
"""Test that the 'can_build' annotation works as expected."""
|
||||
# Create an assembly part
|
||||
assembly = Part.objects.create(
|
||||
name='Assembly Part',
|
||||
description='A part which can be built',
|
||||
assembly=True,
|
||||
component=False,
|
||||
)
|
||||
|
||||
component = Part.objects.create(
|
||||
name='Component Part',
|
||||
description='A component part',
|
||||
assembly=False,
|
||||
component=True,
|
||||
)
|
||||
|
||||
# Create a BOM item for the assembly
|
||||
bom_item = BomItem.objects.create(
|
||||
part=assembly,
|
||||
sub_part=component,
|
||||
quantity=10,
|
||||
setup_quantity=26,
|
||||
attrition=3,
|
||||
rounding_multiple=15,
|
||||
)
|
||||
|
||||
# Create some stock items for the component part
|
||||
StockItem.objects.create(part=component, quantity=5000)
|
||||
|
||||
# expected "can build" quantity
|
||||
N = bom_item.get_required_quantity(1)
|
||||
self.assertEqual(N, 45)
|
||||
|
||||
# Fetch from API
|
||||
response = self.get(
|
||||
reverse('api-bom-item-detail', kwargs={'pk': bom_item.pk}),
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
can_build = response.data['can_build']
|
||||
self.assertAlmostEqual(can_build, 482.9, places=1)
|
||||
|
||||
|
||||
class PartAttachmentTest(InvenTreeAPITestCase):
|
||||
"""Unit tests for the PartAttachment API endpoint."""
|
||||
|
@@ -6,6 +6,7 @@ import django.core.exceptions as django_exceptions
|
||||
from django.db import transaction
|
||||
from django.test import TestCase
|
||||
|
||||
import build.models
|
||||
import stock.models
|
||||
|
||||
from .models import BomItem, BomItemSubstitute, Part
|
||||
@@ -78,32 +79,12 @@ class BomItemTest(TestCase):
|
||||
# But with an integer quantity, should be fine
|
||||
BomItem.objects.create(part=self.bob, sub_part=p, quantity=21)
|
||||
|
||||
def test_overage(self):
|
||||
"""Test that BOM line overages are calculated correctly."""
|
||||
def test_attrition(self):
|
||||
"""Test that BOM line attrition values are calculated correctly."""
|
||||
item = BomItem.objects.get(part=100, sub_part=50)
|
||||
|
||||
q = 300
|
||||
|
||||
item.quantity = q
|
||||
|
||||
# Test empty overage
|
||||
n = item.get_overage_quantity(q)
|
||||
self.assertEqual(n, 0)
|
||||
|
||||
# Test improper overage
|
||||
item.overage = 'asf234?'
|
||||
n = item.get_overage_quantity(q)
|
||||
self.assertEqual(n, 0)
|
||||
|
||||
# Test absolute overage
|
||||
item.overage = '3'
|
||||
n = item.get_overage_quantity(q)
|
||||
self.assertEqual(n, 3)
|
||||
|
||||
# Test percentage-based overage
|
||||
item.overage = '5.0 % '
|
||||
n = item.get_overage_quantity(q)
|
||||
self.assertEqual(n, 15)
|
||||
item.quantity = 300
|
||||
item.attrition = 5 # 5% attrition
|
||||
|
||||
# Calculate total required quantity
|
||||
# Quantity = 300 (+ 5%)
|
||||
@@ -113,6 +94,67 @@ class BomItemTest(TestCase):
|
||||
|
||||
self.assertEqual(n, 3150)
|
||||
|
||||
def test_setup_quantity(self):
|
||||
"""Test the 'setup_quantity' attribute."""
|
||||
item = BomItem.objects.get(pk=4)
|
||||
|
||||
# Default is 0
|
||||
self.assertEqual(item.setup_quantity, 0)
|
||||
self.assertEqual(item.get_required_quantity(1), 3)
|
||||
self.assertEqual(item.get_required_quantity(10), 30)
|
||||
|
||||
item.setup_quantity = 5
|
||||
item.save()
|
||||
|
||||
# Now the required quantity should include the setup quantity
|
||||
self.assertEqual(item.get_required_quantity(1), 8) # 3 + 5 = 8
|
||||
self.assertEqual(item.get_required_quantity(10), 35) # 30 + 5 = 35
|
||||
|
||||
def test_round_up(self):
|
||||
"""Test the 'rounding_multiple' attribute."""
|
||||
item = BomItem.objects.get(pk=4)
|
||||
|
||||
# Default is null
|
||||
self.assertIsNone(item.rounding_multiple)
|
||||
self.assertEqual(item.get_required_quantity(1), 3) # 3 x 1 = 3
|
||||
self.assertEqual(item.get_required_quantity(10), 30) # 3 x 10 = 30
|
||||
self.assertEqual(item.get_required_quantity(25), 75) # 3 x 25 = 75
|
||||
|
||||
# Set a round-up multiple
|
||||
item.rounding_multiple = 17
|
||||
item.save()
|
||||
|
||||
# Now the required quantity should be rounded up to the nearest multiple of 17
|
||||
self.assertEqual(
|
||||
item.get_required_quantity(1), 17
|
||||
) # 3 x 1 = 3, rounded up to nearest multiple of 17
|
||||
self.assertEqual(
|
||||
item.get_required_quantity(2), 17
|
||||
) # 3 x 2 = 6, rounded up to nearest multiple of 17
|
||||
self.assertEqual(
|
||||
item.get_required_quantity(5), 17
|
||||
) # 3 x 5 = 15, rounded up to nearest multiple of 17
|
||||
self.assertEqual(
|
||||
item.get_required_quantity(10), 34
|
||||
) # 3 x 10 = 30, rounded up to nearest multiple of 17
|
||||
self.assertEqual(
|
||||
item.get_required_quantity(100), 306
|
||||
) # 3 x 100 = 300, rounded up to nearest multiple of 17
|
||||
|
||||
# Next, let's create a new Build order
|
||||
bo = build.models.Build.objects.create(
|
||||
part=item.part, quantity=21, reference='BO-9999', title='Test Build Order'
|
||||
)
|
||||
|
||||
# Build line items have been auto created
|
||||
lines = bo.build_lines.all().filter(bom_item=item)
|
||||
self.assertEqual(lines.count(), 1)
|
||||
line = lines.first()
|
||||
|
||||
self.assertEqual(
|
||||
line.quantity, 68
|
||||
) # 3 x 21 = 63, rounded up to nearest multiple of 17
|
||||
|
||||
def test_item_hash(self):
|
||||
"""Test BOM item hash encoding."""
|
||||
item = BomItem.objects.get(part=100, sub_part=50)
|
||||
|
@@ -99,8 +99,12 @@ export function ApiFormField({
|
||||
);
|
||||
|
||||
// Coerce the value to a numerical value
|
||||
const numericalValue: number | '' = useMemo(() => {
|
||||
let val: number | '' = 0;
|
||||
const numericalValue: number | null = useMemo(() => {
|
||||
let val: number | null = 0;
|
||||
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (definition.field_type) {
|
||||
case 'integer':
|
||||
@@ -116,7 +120,7 @@ export function ApiFormField({
|
||||
}
|
||||
|
||||
if (Number.isNaN(val) || !Number.isFinite(val)) {
|
||||
val = '';
|
||||
val = null;
|
||||
}
|
||||
|
||||
return val;
|
||||
@@ -198,10 +202,16 @@ export function ApiFormField({
|
||||
ref={field.ref}
|
||||
id={fieldId}
|
||||
aria-label={`number-field-${field.name}`}
|
||||
value={numericalValue}
|
||||
value={numericalValue === null ? '' : numericalValue}
|
||||
error={definition.error ?? error?.message}
|
||||
decimalScale={definition.field_type == 'integer' ? 0 : 10}
|
||||
onChange={(value: number | string | null) => onChange(value)}
|
||||
onChange={(value: number | string | null) => {
|
||||
if (value != null && value.toString().trim() === '') {
|
||||
onChange(null);
|
||||
} else {
|
||||
onChange(value);
|
||||
}
|
||||
}}
|
||||
step={1}
|
||||
/>
|
||||
);
|
||||
|
@@ -29,12 +29,14 @@ export function bomItemFields(): ApiFormFieldSet {
|
||||
},
|
||||
quantity: {},
|
||||
reference: {},
|
||||
overage: {},
|
||||
note: {},
|
||||
setup_quantity: {},
|
||||
attrition: {},
|
||||
rounding_multiple: {},
|
||||
allow_variants: {},
|
||||
inherited: {},
|
||||
consumable: {},
|
||||
optional: {}
|
||||
optional: {},
|
||||
note: {}
|
||||
};
|
||||
}
|
||||
|
||||
|
@@ -140,14 +140,73 @@ export function BomTable({
|
||||
const units = record.sub_part_detail?.units;
|
||||
|
||||
return (
|
||||
<Group justify='space-between' grow>
|
||||
<Text>{quantity}</Text>
|
||||
{record.overage && <Text size='xs'>+{record.overage}</Text>}
|
||||
{units && <Text size='xs'>{units}</Text>}
|
||||
<Group justify='space-between'>
|
||||
<Group gap='xs'>
|
||||
<Text>{quantity}</Text>
|
||||
{record.setup_quantity && record.setup_quantity > 0 && (
|
||||
<Text size='xs'>{`(+${record.setup_quantity})`}</Text>
|
||||
)}
|
||||
{record.attrition && record.attrition > 0 && (
|
||||
<Text size='xs'>{`(+${record.attrition}%)`}</Text>
|
||||
)}
|
||||
</Group>
|
||||
{units && <Text size='xs'>[{units}]</Text>}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'setup_quantity',
|
||||
defaultVisible: false,
|
||||
sortable: true,
|
||||
render: (record: any) => {
|
||||
const setup_quantity = record.setup_quantity;
|
||||
const units = record.sub_part_detail?.units;
|
||||
if (setup_quantity == null || setup_quantity === 0) {
|
||||
return '-';
|
||||
} else {
|
||||
return (
|
||||
<Group gap='xs' justify='space-between'>
|
||||
<Text size='xs'>{formatDecimal(setup_quantity)}</Text>
|
||||
{units && <Text size='xs'>[{units}]</Text>}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'attrition',
|
||||
defaultVisible: false,
|
||||
sortable: true,
|
||||
render: (record: any) => {
|
||||
const attrition = record.attrition;
|
||||
if (attrition == null || attrition === 0) {
|
||||
return '-';
|
||||
} else {
|
||||
return <Text size='xs'>{`${formatDecimal(attrition)}%`}</Text>;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'rounding_multiple',
|
||||
defaultVisible: false,
|
||||
sortable: false,
|
||||
render: (record: any) => {
|
||||
const units = record.sub_part_detail?.units;
|
||||
const multiple: number | null = record.round_up_multiple;
|
||||
|
||||
if (multiple == null) {
|
||||
return '-';
|
||||
} else {
|
||||
return (
|
||||
<Group gap='xs' justify='space-between'>
|
||||
<Text>{formatDecimal(multiple)}</Text>
|
||||
{units && <Text size='xs'>[{units}]</Text>}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'substitutes',
|
||||
defaultVisible: false,
|
||||
|
@@ -392,14 +392,48 @@ export default function BuildLineTable({
|
||||
defaultVisible: false,
|
||||
switchable: false,
|
||||
render: (record: any) => {
|
||||
// Include information about the BOM item (if available)
|
||||
const extra: any[] = [];
|
||||
|
||||
if (record?.bom_item_detail?.setup_quantity) {
|
||||
extra.push(
|
||||
<Text key='setup-quantity' size='sm'>
|
||||
{t`Setup Quantity`}: {record.bom_item_detail.setup_quantity}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (record?.bom_item_detail?.attrition) {
|
||||
extra.push(
|
||||
<Text key='attrition' size='sm'>
|
||||
{t`Attrition`}: {record.bom_item_detail.attrition}%
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (record?.bom_item_detail?.rounding_multiple) {
|
||||
extra.push(
|
||||
<Text key='rounding-multiple' size='sm'>
|
||||
{t`Rounding Multiple`}:{' '}
|
||||
{record.bom_item_detail.rounding_multiple}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// If a build output is specified, use the provided quantity
|
||||
return (
|
||||
<Group justify='space-between' wrap='nowrap'>
|
||||
<Text>{record.requiredQuantity}</Text>
|
||||
{record?.part_detail?.units && (
|
||||
<Text size='xs'>[{record.part_detail.units}]</Text>
|
||||
)}
|
||||
</Group>
|
||||
<TableHoverCard
|
||||
title={t`BOM Information`}
|
||||
extra={extra}
|
||||
value={
|
||||
<Group justify='space-between' wrap='nowrap'>
|
||||
<Text>{record.requiredQuantity}</Text>
|
||||
{record?.part_detail?.units && (
|
||||
<Text size='xs'>[{record.part_detail.units}]</Text>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
|
@@ -234,6 +234,8 @@ test('Build Order - Allocation', async ({ browser }) => {
|
||||
await page.getByText('Reel Storage').waitFor();
|
||||
await page.getByText('R_10K_0805_1%').first().click();
|
||||
|
||||
await page.reload();
|
||||
|
||||
// The capacitor stock should be fully allocated
|
||||
const cell = await page.getByRole('cell', { name: /C_1uF_0805/ });
|
||||
const row = await getRowFromCell(cell);
|
||||
@@ -278,7 +280,7 @@ test('Build Order - Allocation', async ({ browser }) => {
|
||||
{
|
||||
name: 'Blue Widget',
|
||||
ipn: 'widget.blue',
|
||||
available: '39',
|
||||
available: '129',
|
||||
required: '5',
|
||||
allocated: '5'
|
||||
},
|
||||
@@ -313,7 +315,7 @@ test('Build Order - Allocation', async ({ browser }) => {
|
||||
|
||||
// Check for expected buttons on Red Widget
|
||||
const redWidget = await page.getByRole('cell', { name: 'Red Widget' });
|
||||
const redRow = await redWidget.locator('xpath=ancestor::tr').first();
|
||||
const redRow = await getRowFromCell(redWidget);
|
||||
|
||||
await redRow.getByLabel(/row-action-menu-/i).click();
|
||||
await page
|
||||
@@ -426,3 +428,33 @@ test('Build Order - External', async ({ browser }) => {
|
||||
await page.getByRole('cell', { name: 'PO0017' }).waitFor();
|
||||
await page.getByRole('cell', { name: 'PO0018' }).waitFor();
|
||||
});
|
||||
|
||||
test('Build Order - BOM Quantity', async ({ browser }) => {
|
||||
// Validate required build order quantities (based on BOM values)
|
||||
|
||||
const page = await doCachedLogin(browser, { url: 'part/81/bom' });
|
||||
|
||||
// Expected quantity values for the BOM items
|
||||
await page.getByText('15(+50)').waitFor();
|
||||
await page.getByText('10(+100)').waitFor();
|
||||
|
||||
await loadTab(page, 'Part Details');
|
||||
|
||||
// Expected "can build" value: 13
|
||||
const canBuild = await page
|
||||
.getByRole('cell', { name: 'Can Build' })
|
||||
.locator('div');
|
||||
const row = await getRowFromCell(canBuild);
|
||||
await row.getByText('13').waitFor();
|
||||
|
||||
await loadTab(page, 'Build Orders');
|
||||
await page.getByRole('cell', { name: 'BO0016' }).click();
|
||||
|
||||
await loadTab(page, 'Required Parts');
|
||||
|
||||
const line = await page
|
||||
.getByRole('cell', { name: 'Thumbnail R_10K_0805_1%' })
|
||||
.locator('div');
|
||||
const row2 = await getRowFromCell(line);
|
||||
await row2.getByText('1175').waitFor();
|
||||
});
|
||||
|
@@ -207,15 +207,17 @@ test('Stock - Serialize', async ({ browser }) => {
|
||||
await page.getByLabel('text-field-serial_numbers').fill('200-250');
|
||||
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
await page
|
||||
.getByText('Group range 200-250 exceeds allowed quantity')
|
||||
.getByText('Number of unique serial numbers (51) must match quantity (100)')
|
||||
.waitFor();
|
||||
|
||||
await page.getByLabel('text-field-serial_numbers').fill('1, 2, 3');
|
||||
await page.waitForTimeout(250);
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
await page
|
||||
.getByText('Number of unique serial numbers (3) must match quantity (10)')
|
||||
.getByText('Number of unique serial numbers (3) must match quantity (100)')
|
||||
.waitFor();
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
Reference in New Issue
Block a user