mirror of
https://github.com/inventree/InvenTree.git
synced 2025-07-30 00:21: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:
@@ -17,9 +17,11 @@ A BOM for a particular assembly is comprised of a number (zero or more) of BOM "
|
||||
| Property | Description |
|
||||
| --- | --- |
|
||||
| Part | A reference to another *Part* object which is required to build this assembly |
|
||||
| Quantity | The quantity of *Part* required for the assembly |
|
||||
| Reference | Optional reference field to describe the BOM Line Item, e.g. part designator |
|
||||
| Overage | Estimated losses for a build. Can be expressed as absolute values (e.g. 1, 7, etc) or as a percentage (e.g. 2%) |
|
||||
| Quantity | The quantity of *Part* required for the assembly |
|
||||
| Attrition | Estimated attrition losses for a production run. Expressed as a percentage of the base quantity (e.g. 2%) |
|
||||
| Setup Quantity | An additional quantity of the part which is required to account for fixed setup losses during the production process. This is added to the base quantity of the BOM line item |
|
||||
| Rounding Multiple | A value which indicates that the required quantity should be rounded up to the nearest multiple of this value. |
|
||||
| Consumable | A boolean field which indicates whether this BOM Line Item is *consumable* |
|
||||
| Inherited | A boolean field which indicates whether this BOM Line Item will be "inherited" by BOMs for parts which are a variant (or sub-variant) of the part for which this BOM is defined. |
|
||||
| Optional | A boolean field which indicates if this BOM Line Item is "optional" |
|
||||
@@ -96,7 +98,7 @@ The `Create BOM Item` form will be displayed:
|
||||
|
||||
{{ image("build/bom_add_item.png", "Create BOM Item Form") }}
|
||||
|
||||
Fill-out the `Quantity` (required), `Reference`, `Overage` and `Note` (optional) fields then click on <span class="badge inventree confirm">Submit</span> to add the BOM item to this part's BOM.
|
||||
Fill-out the required fields then click on <span class="badge inventree confirm">Submit</span> to add the BOM item to this part's BOM.
|
||||
|
||||
### Add Substitute for BOM Item
|
||||
|
||||
@@ -111,3 +113,90 @@ Select a part in the list and click on "Add Substitute" button to confirm.
|
||||
## Multi Level BOMs
|
||||
|
||||
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.
|
||||
|
||||
## 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:
|
||||
|
||||
### Base Quantity
|
||||
|
||||
The base quantity of a BOM line item is defined by the `Quantity` field of the BOM line item. This is the number of parts which are required to build one assembly. This value is multiplied by the number of assemblies which are being built to determine the total quantity of parts required.
|
||||
|
||||
```
|
||||
Required Quantity = Base Quantity * Number of Assemblies
|
||||
```
|
||||
|
||||
### Attrition
|
||||
|
||||
The `Attrition` field of a BOM line item is used to account for expected losses during the production process. This is expressed as a percentage of the `Base Quantity` (e.g. 2%).
|
||||
|
||||
If a non-zero attrition percentage is specified, it is applied to the calculated `Required Quantity` value.
|
||||
|
||||
```
|
||||
Required Quantity = Required Quantity * (1 + Attrition Percentage)
|
||||
```
|
||||
|
||||
!!! info "Optional"
|
||||
The attrition percentage is optional. If not specified, it defaults to 0%.
|
||||
|
||||
### Setup Quantity
|
||||
|
||||
The `Setup Quantity` field of a BOM line item is used to account for fixed losses during the production process. This is an additional quantity of the part which is required to ensure that the production run can be completed successfully. This value is added to the calculated `Required Quantity`.
|
||||
|
||||
```
|
||||
Required Quantity = Required Quantity + Setup Quantity
|
||||
```
|
||||
|
||||
!!! info "Optional"
|
||||
The setup quantity is optional. If not specified, it defaults to 0.
|
||||
|
||||
### Rounding Multiple
|
||||
|
||||
The `Rounding Multiple` field of a BOM line item is used to round the calculated `Required Quantity` value to the nearest multiple of the specified value. This is useful for ensuring that the required quantity is a whole number, or to meet specific packaging requirements.
|
||||
|
||||
```
|
||||
Required Quantity = ceil(Required Quantity / Rounding Multiple) * Rounding Multiple
|
||||
```
|
||||
|
||||
!!! info "Optional"
|
||||
The rounding multiple is optional. If not specified, no rounding is applied to the calculated production quantity.
|
||||
|
||||
### Example Calculation
|
||||
|
||||
Consider a BOM line item with the following properties:
|
||||
|
||||
- Base Quantity: 3
|
||||
- Attrition: 2% (0.02)
|
||||
- Setup Quantity: 10
|
||||
- Rounding Multiple: 25
|
||||
|
||||
If we are building 100 assemblies, the required quantity would be calculated as follows:
|
||||
|
||||
```
|
||||
Required Quantity = Base Quantity * Number of Assemblies
|
||||
= 3 * 100
|
||||
= 300
|
||||
|
||||
Attrition Value = Required Quantity * Attrition Percentage
|
||||
= 300 * 0.02
|
||||
= 6
|
||||
|
||||
Required Quantity = Required Quantity + Attrition Value
|
||||
= 300 + 6
|
||||
= 306
|
||||
|
||||
Required Quantity = Required Quantity + Setup Quantity
|
||||
= 306 + 10
|
||||
= 316
|
||||
|
||||
Required Quantity = ceil(Required Quantity / Rounding Multiple) * Rounding Multiple
|
||||
= ceil(316 / 25) * 25
|
||||
= 13 * 25
|
||||
= 325
|
||||
|
||||
```
|
||||
|
||||
So the final required production quantity of the component part would be `325`.
|
||||
|
||||
!!! info "Calculation"
|
||||
The required quantity calculation is performed automatically when a new [Build Order](./build.md) is created.
|
||||
|
@@ -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