mirror of
https://github.com/inventree/InvenTree.git
synced 2026-10-28 12:24:02 +00:00
[bug] Can build fix (#13090)
* Fix 'can_build' calculation - Take 'piece_count' into consideration - Update queryset annotations - Update quantity calculation call sites * Updated docs
This commit is contained in:
1 parent
66e3278ea7
commit
43956b9324
19 files changed
+368
-25
No files matched your search
@@ -77,6 +77,9 @@ An assembly requires 4 pieces of tubing, each 500mm long, with 5% attrition:
|
||||
!!! note "Default Behavior"
|
||||
When `piece_count` is left at its default value of 1, the BOM line item behaves exactly as it did before this feature was introduced. Existing BOMs are unaffected.
|
||||
|
||||
!!! info "Where Piece Count Applies"
|
||||
The total material per assembly (`quantity x piece_count`) is used wherever InvenTree calculates component requirements for an assembly, including the [required build quantity](./required.md), the *Can Build* quantity, [BOM pricing](../part/pricing.md#bom-pricing), and the *Total Quantity* column of the [BOM Exporter](../plugins/builtin/bom_exporter.md).
|
||||
|
||||
!!! tip "When to Use Piece Count"
|
||||
Use `piece_count` when you are cutting or dividing a material into multiple identical pieces. If each piece has a different length, create separate BOM line items instead.
|
||||
|
||||
@@ -216,7 +219,7 @@ In the example above, the *Doohickey* sub-assembly is required three times for e
|
||||
Other columns (such as *Available Stock*) display the same values as they would in the BOM for the sub-assembly itself.
|
||||
|
||||
!!! info "Setup Quantity and Attrition"
|
||||
The calculated quantity values are based on the line item quantities only. The [setup quantity](#bom-line-items) and [attrition](#bom-line-items) of any upstream sub-assemblies are not included in the calculation.
|
||||
The calculated quantity values are based on the line item quantities only. The quantity of each upstream sub-assembly includes its [piece count](#piece-count-cut-to-length-parts) (`quantity x piece_count`). The [setup quantity](#bom-line-items) and [attrition](#bom-line-items) of any upstream sub-assemblies are not included in the calculation.
|
||||
|
||||
#### Behavior
|
||||
|
||||
|
||||
@@ -14,6 +14,17 @@ The base quantity of a BOM line item is defined by the `Quantity` field of the B
|
||||
Required Quantity = Base Quantity * Number of Assemblies
|
||||
```
|
||||
|
||||
### Piece Count
|
||||
|
||||
The `Piece Count` field of a BOM line item is used for [cut-to-length parts](./bom.md#piece-count-cut-to-length-parts), where the `Quantity` field represents the size (e.g. length) of each individual piece. The base quantity is multiplied by the piece count to determine the total material required for each assembly.
|
||||
|
||||
```
|
||||
Required Quantity = Base Quantity * Piece Count * Number of Assemblies
|
||||
```
|
||||
|
||||
!!! info "Optional"
|
||||
The piece count is optional. If not specified, it defaults to 1.
|
||||
|
||||
### 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%).
|
||||
@@ -54,6 +65,7 @@ Required Quantity = ceil(Required Quantity / Rounding Multiple) * Rounding Multi
|
||||
Consider a BOM line item with the following properties:
|
||||
|
||||
- Base Quantity: 3
|
||||
- Piece Count: 1
|
||||
- Attrition: 2% (0.02)
|
||||
- Setup Quantity: 10
|
||||
- Rounding Multiple: 25
|
||||
@@ -61,8 +73,8 @@ Consider a BOM line item with the following properties:
|
||||
If we are building 100 assemblies, the required quantity would be calculated as follows:
|
||||
|
||||
```
|
||||
Required Quantity = Base Quantity * Number of Assemblies
|
||||
= 3 * 100
|
||||
Required Quantity = Base Quantity * Piece Count * Number of Assemblies
|
||||
= 3 * 1 * 100
|
||||
= 300
|
||||
|
||||
Attrition Value = Required Quantity * Attrition Percentage
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 563
|
||||
INVENTREE_API_VERSION = 564
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v564 -> 2026-10-24 : https://github.com/inventree/InvenTree/pull/xxxxx
|
||||
- BomItem "can_build", "pricing_min_total" and "pricing_max_total" fields now account for "piece_count"
|
||||
- BuildLine "unit_quantity" ordering now accounts for the BOM "piece_count"
|
||||
|
||||
v563 -> 2026-10-22 : https://github.com/inventree/InvenTree/pull/13030
|
||||
- Adds optional 'link_nonce' mechanism to the API root
|
||||
|
||||
|
||||
@@ -3,7 +3,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import DecimalField, F, OuterRef, Q, Subquery, Sum
|
||||
from django.db.models import (
|
||||
DecimalField,
|
||||
ExpressionWrapper,
|
||||
F,
|
||||
OuterRef,
|
||||
Q,
|
||||
Subquery,
|
||||
Sum,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.urls import include, path
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -614,10 +622,18 @@ class BuildLineMixin(SerializerContextMixin):
|
||||
|
||||
source_build = self.source_build
|
||||
|
||||
return build.serializers.BuildLineSerializer.annotate_queryset(
|
||||
queryset = build.serializers.BuildLineSerializer.annotate_queryset(
|
||||
queryset, build=source_build
|
||||
)
|
||||
|
||||
# Quantity required per assembly (used for ordering)
|
||||
return queryset.alias(
|
||||
unit_quantity=ExpressionWrapper(
|
||||
F('bom_item__quantity') * F('bom_item__piece_count'),
|
||||
output_field=DecimalField(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class BuildLineOutputOptions(OutputConfiguration):
|
||||
"""Output options for BuildLine endpoint."""
|
||||
@@ -682,7 +698,6 @@ class BuildLineList(
|
||||
'part': 'bom_item__sub_part__name',
|
||||
'IPN': 'bom_item__sub_part__IPN',
|
||||
'reference': 'bom_item__reference',
|
||||
'unit_quantity': 'bom_item__quantity',
|
||||
'category': 'bom_item__sub_part__category__name',
|
||||
'consumable': 'bom_item__consumable',
|
||||
'optional': 'bom_item__optional',
|
||||
|
||||
@@ -2056,7 +2056,7 @@ class Build(
|
||||
)
|
||||
|
||||
# The amount allocated against an output must at least equal the BOM quantity
|
||||
if allocated['q'] < line.bom_item.quantity:
|
||||
if allocated['q'] < line.bom_item.unit_quantity:
|
||||
return False
|
||||
|
||||
# At this stage, we can assume that the output is fully allocated
|
||||
|
||||
@@ -489,7 +489,7 @@ def check_build_stock(build):
|
||||
|
||||
available = max(0, in_stock - allocated)
|
||||
|
||||
required = Decimal(bom_item.quantity) * Decimal(build.quantity)
|
||||
required = bom_item.unit_quantity * Decimal(build.quantity)
|
||||
|
||||
if available < required:
|
||||
# There is not sufficient stock for this part
|
||||
|
||||
@@ -2280,6 +2280,43 @@ class BuildLineTests(BuildAPITest):
|
||||
line_data = next(item for item in response.data if item['pk'] == line.pk)
|
||||
self.assertEqual(line_data['allocated'], 1)
|
||||
|
||||
def test_ordering_unit_quantity_piece_count(self):
|
||||
"""Ordering by 'unit_quantity' must account for the BOM piece_count."""
|
||||
assembly = Part.objects.create(
|
||||
name='Unit Quantity Assembly',
|
||||
description='Assembly for unit quantity ordering',
|
||||
assembly=True,
|
||||
)
|
||||
|
||||
wire = Part.objects.create(
|
||||
name='Wire', description='Cut-to-length wire', component=True
|
||||
)
|
||||
screw = Part.objects.create(name='Screw', description='A screw', component=True)
|
||||
|
||||
# 2 x 5 = 10 units of wire per assembly
|
||||
BomItem.objects.create(part=assembly, sub_part=wire, quantity=2, piece_count=5)
|
||||
|
||||
# 3 x 1 = 3 screws per assembly
|
||||
BomItem.objects.create(part=assembly, sub_part=screw, quantity=3)
|
||||
|
||||
build = Build.objects.create(
|
||||
part=assembly, reference='BO-9998', quantity=1, title='Unit quantity'
|
||||
)
|
||||
|
||||
url = reverse('api-build-line-list')
|
||||
|
||||
response = self.get(
|
||||
url, {'build': build.pk, 'ordering': 'unit_quantity'}, expected_code=200
|
||||
)
|
||||
|
||||
self.assertEqual([line['part'] for line in response.data], [screw.pk, wire.pk])
|
||||
|
||||
response = self.get(
|
||||
url, {'build': build.pk, 'ordering': '-unit_quantity'}, expected_code=200
|
||||
)
|
||||
|
||||
self.assertEqual([line['part'] for line in response.data], [wire.pk, screw.pk])
|
||||
|
||||
|
||||
class BuildConsumeTest(BuildAPITest):
|
||||
"""Test consuming allocated stock."""
|
||||
|
||||
@@ -20,6 +20,7 @@ from django_q.models import OrmQ
|
||||
|
||||
import build.tasks
|
||||
import common.models
|
||||
import common.notifications
|
||||
import company.models
|
||||
from build.models import Build, BuildItem, BuildLine, generate_next_build_reference
|
||||
from build.status_codes import BuildStatus
|
||||
@@ -442,6 +443,45 @@ class BuildTest(BuildTestBase):
|
||||
|
||||
self.assertTrue(self.build.is_fully_allocated(tracked=False))
|
||||
|
||||
def test_output_allocation_piece_count(self):
|
||||
"""Tracked output allocation must account for the BOM piece_count."""
|
||||
# 2 x 3 = 6 units of sub_part_3 required per output
|
||||
self.bom_item_3.piece_count = 3
|
||||
self.bom_item_3.save()
|
||||
|
||||
# Allocating the bare BOM quantity is not sufficient
|
||||
self.allocate_stock(self.output_1, {self.stock_3_1: 2})
|
||||
self.assertFalse(self.build.is_output_fully_allocated(self.output_1))
|
||||
|
||||
BuildItem.objects.filter(install_into=self.output_1).delete()
|
||||
|
||||
self.allocate_stock(self.output_1, {self.stock_3_1: 6})
|
||||
self.assertTrue(self.build.is_output_fully_allocated(self.output_1))
|
||||
|
||||
def test_check_build_stock_piece_count(self):
|
||||
"""The 'stock required' notification must account for the BOM piece_count."""
|
||||
|
||||
def required_lines():
|
||||
with mock.patch.object(
|
||||
common.notifications, 'trigger_notification'
|
||||
) as notify:
|
||||
build.tasks.check_build_stock(self.build)
|
||||
|
||||
if not notify.called:
|
||||
return {}
|
||||
|
||||
context = notify.call_args.kwargs['context']
|
||||
return {line['part'].pk: line['required'] for line in context['lines']}
|
||||
|
||||
# 5 x 10 = 50 required, 103 available
|
||||
self.assertNotIn(self.sub_part_1.pk, required_lines())
|
||||
|
||||
# 5 x 3 x 10 = 150 required, 103 available
|
||||
self.bom_item_1.piece_count = 3
|
||||
self.bom_item_1.save()
|
||||
|
||||
self.assertEqual(required_lines()[self.sub_part_1.pk], 150)
|
||||
|
||||
def test_overallocation_and_trim(self):
|
||||
"""Test overallocation of stock and trim function."""
|
||||
self.assertEqual(self.build.status, status.BuildStatus.PENDING)
|
||||
|
||||
@@ -553,7 +553,11 @@ def annotate_bom_item_can_build(queryset: QuerySet, reference: str = '') -> Quer
|
||||
Case(
|
||||
When(Q(quantity=0), then=Value(0)),
|
||||
default=(F('total_stock') - F('setup_quantity'))
|
||||
/ (F('quantity') * (1.0 + F('attrition') / 100.0)),
|
||||
/ (
|
||||
F('quantity')
|
||||
* F('piece_count')
|
||||
* (1.0 + F('attrition') / 100.0)
|
||||
),
|
||||
output_field=FloatField(),
|
||||
),
|
||||
output_field=FloatField(),
|
||||
|
||||
@@ -3446,6 +3446,15 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
|
||||
except Part.DoesNotExist:
|
||||
raise ValidationError({'sub_part': _('Sub part must be specified')})
|
||||
|
||||
@property
|
||||
def unit_quantity(self) -> Decimal:
|
||||
"""Return the quantity of sub_part required to produce a single assembly.
|
||||
|
||||
This accounts for the piece_count (total material = quantity x piece_count),
|
||||
but does not include attrition, setup quantity or rounding.
|
||||
"""
|
||||
return Decimal(self.quantity) * Decimal(self.piece_count)
|
||||
|
||||
def can_build_quantity(self, available_stock: float) -> int:
|
||||
"""Calculate the number of assemblies that can be built with the available stock.
|
||||
|
||||
@@ -3458,7 +3467,7 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
|
||||
"""
|
||||
# Account for setup quantity
|
||||
available_stock = Decimal(max(0, available_stock - self.setup_quantity))
|
||||
quantity_decimal = Decimal(self.quantity)
|
||||
quantity_decimal = self.unit_quantity
|
||||
attrition_decimal = Decimal(self.attrition) / 100
|
||||
n = quantity_decimal * (1 + attrition_decimal)
|
||||
|
||||
|
||||
@@ -1674,11 +1674,15 @@ class BomItemSerializer(
|
||||
# Annotate with the 'total pricing' information based on unit pricing and quantity
|
||||
queryset = queryset.annotate(
|
||||
pricing_min_total=ExpressionWrapper(
|
||||
F('quantity') * F('sub_part__pricing_data__overall_min'),
|
||||
F('quantity')
|
||||
* F('piece_count')
|
||||
* F('sub_part__pricing_data__overall_min'),
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
pricing_max_total=ExpressionWrapper(
|
||||
F('quantity') * F('sub_part__pricing_data__overall_max'),
|
||||
F('quantity')
|
||||
* F('piece_count')
|
||||
* F('sub_part__pricing_data__overall_max'),
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ from django.test.utils import CaptureQueriesContext, override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
import pytest
|
||||
from djmoney.money import Money
|
||||
from PIL import Image
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -3481,6 +3482,113 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
can_build = response.data['can_build']
|
||||
self.assertAlmostEqual(can_build, 482.9, places=1)
|
||||
|
||||
def test_piece_count_can_build(self):
|
||||
"""Test that the 'can_build' annotation accounts for piece_count."""
|
||||
assembly = Part.objects.create(
|
||||
name='Cable Assembly', description='A cable assembly', assembly=True
|
||||
)
|
||||
|
||||
wire = Part.objects.create(
|
||||
name='Wire', description='Cut-to-length wire', component=True
|
||||
)
|
||||
|
||||
bom_item = BomItem.objects.create(
|
||||
part=assembly, sub_part=wire, quantity=2, piece_count=5
|
||||
)
|
||||
|
||||
StockItem.objects.create(part=wire, quantity=20)
|
||||
|
||||
response = self.get(
|
||||
reverse('api-bom-item-detail', kwargs={'pk': bom_item.pk}),
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
# 20 / (2 * 5) = 2
|
||||
self.assertEqual(response.data['piece_count'], 5)
|
||||
self.assertAlmostEqual(response.data['can_build'], 2)
|
||||
|
||||
# The part requirements endpoint also accounts for piece_count
|
||||
response = self.get(
|
||||
reverse('api-part-requirements', kwargs={'pk': assembly.pk}),
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
self.assertEqual(response.data['can_build'], 2)
|
||||
|
||||
def test_piece_count_pricing(self):
|
||||
"""Test that BOM pricing totals account for piece_count."""
|
||||
assembly = Part.objects.create(
|
||||
name='Cable Assembly', description='A cable assembly', assembly=True
|
||||
)
|
||||
|
||||
wire = Part.objects.create(
|
||||
name='Wire', description='Cut-to-length wire', component=True
|
||||
)
|
||||
|
||||
pricing = wire.pricing
|
||||
pricing.internal_cost_min = Money(2, 'USD')
|
||||
pricing.internal_cost_max = Money(3, 'USD')
|
||||
pricing.save()
|
||||
|
||||
bom_item = BomItem.objects.create(
|
||||
part=assembly, sub_part=wire, quantity=4, piece_count=5
|
||||
)
|
||||
|
||||
self.assignRole('pricing.view')
|
||||
|
||||
response = self.get(
|
||||
reverse('api-bom-item-detail', kwargs={'pk': bom_item.pk}),
|
||||
{'pricing': True},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
# 4 x 5 = 20 units required per assembly
|
||||
self.assertAlmostEqual(float(response.data['pricing_min_total']), 40)
|
||||
self.assertAlmostEqual(float(response.data['pricing_max_total']), 60)
|
||||
|
||||
def test_piece_count_bom_export(self):
|
||||
"""Test that the BOM exporter total quantity accounts for piece_count."""
|
||||
assembly = Part.objects.create(
|
||||
name='Top Assembly', description='Top level assembly', assembly=True
|
||||
)
|
||||
|
||||
sub_assembly = Part.objects.create(
|
||||
name='Sub Assembly',
|
||||
description='A sub-assembly',
|
||||
assembly=True,
|
||||
component=True,
|
||||
)
|
||||
|
||||
wire = Part.objects.create(
|
||||
name='Wire', description='Cut-to-length wire', component=True
|
||||
)
|
||||
|
||||
BomItem.objects.create(
|
||||
part=assembly, sub_part=sub_assembly, quantity=2, piece_count=3
|
||||
)
|
||||
BomItem.objects.create(
|
||||
part=sub_assembly, sub_part=wire, quantity=4, piece_count=5
|
||||
)
|
||||
|
||||
with self.export_data(
|
||||
reverse('api-bom-list'),
|
||||
{'part': assembly.pk, 'export_levels': 0},
|
||||
export_plugin='bom-exporter',
|
||||
) as data_file:
|
||||
data = self.process_csv(
|
||||
data_file,
|
||||
required_cols=['BOM Level', 'Total Quantity'],
|
||||
required_rows=2,
|
||||
)
|
||||
|
||||
totals = {str(row['BOM Level']): Decimal(row['Total Quantity']) for row in data}
|
||||
|
||||
# Level 1: 2 x 3 = 6 sub-assemblies
|
||||
self.assertEqual(totals['1'], 6)
|
||||
|
||||
# Level 2: (4 x 5) x 6 = 120 units of wire
|
||||
self.assertEqual(totals['2'], 120)
|
||||
|
||||
def test_piece_count_get(self):
|
||||
"""Test that piece_count is returned in GET response for BomItem."""
|
||||
bom_item = BomItem.objects.first()
|
||||
|
||||
@@ -9,6 +9,7 @@ from django.db import transaction
|
||||
from django.test import TestCase
|
||||
|
||||
import build.models
|
||||
import part.filters
|
||||
import stock.models
|
||||
from common.settings import set_global_setting
|
||||
|
||||
@@ -783,6 +784,67 @@ class BomItemTest(TestCase):
|
||||
# 200 * 10 * 10 = 20000
|
||||
self.assertEqual(item.get_required_quantity(10), 20000)
|
||||
|
||||
def test_piece_count_unit_quantity(self):
|
||||
"""Test that unit_quantity accounts for piece_count."""
|
||||
item = BomItem.objects.get(part=100, sub_part=50)
|
||||
|
||||
item.set_quantity(2)
|
||||
item.piece_count = 1
|
||||
self.assertEqual(item.unit_quantity, 2)
|
||||
|
||||
item.piece_count = 5
|
||||
self.assertEqual(item.unit_quantity, 10)
|
||||
|
||||
# Attrition and setup quantity are not included
|
||||
item.attrition = 10
|
||||
item.setup_quantity = 3
|
||||
self.assertEqual(item.unit_quantity, 10)
|
||||
|
||||
def test_piece_count_can_build_quantity(self):
|
||||
"""Test that BomItem.can_build_quantity accounts for piece_count."""
|
||||
item = BomItem.objects.get(part=100, sub_part=50)
|
||||
|
||||
item.set_quantity(2)
|
||||
item.piece_count = 5
|
||||
item.attrition = 0
|
||||
item.setup_quantity = 0
|
||||
|
||||
# 20 / (2 * 5) = 2
|
||||
self.assertEqual(item.can_build_quantity(20), 2)
|
||||
|
||||
# With setup quantity and attrition: (25 - 5) / (2 * 5 * 1.25) = 1.6
|
||||
item.setup_quantity = 5
|
||||
item.attrition = 25
|
||||
self.assertEqual(item.can_build_quantity(25), 1)
|
||||
|
||||
def test_piece_count_can_build(self):
|
||||
"""Test that the 'can_build' annotation accounts for piece_count."""
|
||||
assembly = Part.objects.create(
|
||||
name='Cable assembly', description='Made with wire', assembly=True
|
||||
)
|
||||
|
||||
wire = Part.objects.create(name='Wire', description='Cut-to-length wire')
|
||||
stock.models.StockItem.objects.create(part=wire, quantity=20)
|
||||
|
||||
bom_item = BomItem.objects.create(
|
||||
part=assembly, sub_part=wire, quantity=2, piece_count=5
|
||||
)
|
||||
|
||||
# 20 / (2 * 5) = 2
|
||||
self.assertEqual(assembly.can_build, 2)
|
||||
|
||||
item = part.filters.annotate_bom_item_can_build(
|
||||
BomItem.objects.filter(pk=bom_item.pk)
|
||||
).first()
|
||||
|
||||
self.assertAlmostEqual(item.can_build, 2)
|
||||
|
||||
# Without piece_count, 10 assemblies could be built
|
||||
bom_item.piece_count = 1
|
||||
bom_item.save()
|
||||
|
||||
self.assertEqual(assembly.can_build, 10)
|
||||
|
||||
def test_piece_count_with_attrition(self):
|
||||
"""Test piece_count combined with attrition percentage."""
|
||||
item = BomItem.objects.get(part=100, sub_part=50)
|
||||
|
||||
@@ -241,7 +241,7 @@ class BomExporterPlugin(DataExportMixin, InvenTreePlugin):
|
||||
|
||||
if self.export_total_quantity:
|
||||
# Calculate the total quantity for this BOM item
|
||||
total_quantity = Decimal(bom_item.quantity) * multiplier
|
||||
total_quantity = bom_item.unit_quantity * multiplier
|
||||
row['total_quantity'] = normalize(total_quantity)
|
||||
|
||||
self.bom_data.append(row)
|
||||
@@ -258,7 +258,7 @@ class BomExporterPlugin(DataExportMixin, InvenTreePlugin):
|
||||
self.process_bom_row(
|
||||
item,
|
||||
level=level + 1,
|
||||
multiplier=multiplier * bom_item.quantity,
|
||||
multiplier=multiplier * bom_item.unit_quantity,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
@@ -366,13 +366,13 @@ class PartPricing(common.models.MetaMixin):
|
||||
|
||||
# Update cumulative totals
|
||||
if bom_item_min is not None:
|
||||
bom_item_min *= bom_item.quantity
|
||||
bom_item_min *= bom_item.unit_quantity
|
||||
cumulative_min += self.convert(bom_item_min)
|
||||
|
||||
any_min_elements = True
|
||||
|
||||
if bom_item_max is not None:
|
||||
bom_item_max *= bom_item.quantity
|
||||
bom_item_max *= bom_item.unit_quantity
|
||||
cumulative_max += self.convert(bom_item_max)
|
||||
|
||||
any_max_elements = True
|
||||
|
||||
@@ -332,6 +332,33 @@ class PartPricingTests(InvenTreeTestCase):
|
||||
self.assertEqual(pricing.overall_min, Money(expected_min, 'USD'))
|
||||
self.assertEqual(pricing.overall_max, Money(expected_max, 'USD'))
|
||||
|
||||
@override_settings(TESTING_PRICING=True)
|
||||
def test_bom_pricing_piece_count(self):
|
||||
"""BOM pricing must account for the piece_count of each BOM item."""
|
||||
sub_part = part.models.Part.objects.create(
|
||||
name='Wire',
|
||||
description='Cut-to-length wire',
|
||||
component=True,
|
||||
assembly=False,
|
||||
)
|
||||
|
||||
sub_part_pricing = sub_part.pricing
|
||||
sub_part_pricing.internal_cost_min = Money(2, 'USD')
|
||||
sub_part_pricing.internal_cost_max = Money(3, 'USD')
|
||||
sub_part_pricing.save()
|
||||
|
||||
part.models.BomItem.objects.create(
|
||||
part=self.part, sub_part=sub_part, quantity=4, piece_count=5
|
||||
)
|
||||
|
||||
self.part.refresh_from_db()
|
||||
pricing = self.part.pricing
|
||||
pricing.update_bom_cost()
|
||||
|
||||
# 4 x 5 = 20 units required per assembly
|
||||
self.assertEqual(pricing.bom_cost_min, Money(40, 'USD'))
|
||||
self.assertEqual(pricing.bom_cost_max, Money(60, 'USD'))
|
||||
|
||||
@override_settings(TESTING_PRICING=True)
|
||||
def test_purchase_pricing(self):
|
||||
"""Unit tests for historical purchase pricing."""
|
||||
|
||||
@@ -85,6 +85,11 @@ function quantityChain(record: any): number[] {
|
||||
return record.quantity_chain ?? [Number(record.quantity)];
|
||||
}
|
||||
|
||||
// Quantity of a BomItem required for a single parent assembly (quantity x piece_count)
|
||||
function unitQuantity(record: any): number {
|
||||
return Number(record.quantity) * Number(record.piece_count ?? 1);
|
||||
}
|
||||
|
||||
// Quantity of a BomItem required for a single unit of the top-level assembly
|
||||
function totalQuantity(record: any): number {
|
||||
return quantityChain(record).reduce((total, qty) => total * qty, 1);
|
||||
@@ -821,7 +826,11 @@ export function BomTable({
|
||||
// Multiply quantities through each level of the BOM
|
||||
transformChild: (child: any, parent: any) => ({
|
||||
...child,
|
||||
quantity_chain: [Number(child.quantity), ...quantityChain(parent)]
|
||||
quantity_chain: [
|
||||
Number(child.quantity),
|
||||
unitQuantity(parent),
|
||||
...quantityChain(parent).slice(1)
|
||||
]
|
||||
})
|
||||
};
|
||||
}, [isEditing, showSubassemblies]);
|
||||
|
||||
@@ -69,6 +69,14 @@ function isLineConsumable(record: any): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the quantity of a BOM item required for a single assembly,
|
||||
* accounting for the piece count (total material = quantity x piece_count).
|
||||
*/
|
||||
export function bomUnitQuantity(bom_item: any): number {
|
||||
return Number(bom_item?.quantity ?? 0) * Number(bom_item?.piece_count ?? 1);
|
||||
}
|
||||
|
||||
function allocationPartDiffers(allocation: any, bomPartId: number): boolean {
|
||||
const allocatedPartId = allocation.part_detail?.pk;
|
||||
|
||||
@@ -465,7 +473,9 @@ export default function BuildLineTable({
|
||||
render: (record: any) => {
|
||||
return (
|
||||
<Group justify='space-between' wrap='nowrap'>
|
||||
<Text>{record.bom_item_detail?.quantity}</Text>
|
||||
<Text>
|
||||
{formatDecimal(bomUnitQuantity(record.bom_item_detail))}
|
||||
</Text>
|
||||
{record?.part_detail?.units && (
|
||||
<Text size='xs'>[{record.part_detail.units}]</Text>
|
||||
)}
|
||||
@@ -581,7 +591,7 @@ export default function BuildLineTable({
|
||||
|
||||
if (output?.pk) {
|
||||
// If an output is specified, we show the allocated quantity for that output
|
||||
required = record.bom_item_detail?.quantity;
|
||||
required = bomUnitQuantity(record.bom_item_detail);
|
||||
}
|
||||
|
||||
if (allocated <= 0 && required <= 0) {
|
||||
|
||||
@@ -73,7 +73,7 @@ import {
|
||||
import useStatusCodes from '../../hooks/UseStatusCodes';
|
||||
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import BuildLineTable from './BuildLineTable';
|
||||
import BuildLineTable, { bomUnitQuantity } from './BuildLineTable';
|
||||
|
||||
type TestResultOverview = {
|
||||
name: string;
|
||||
@@ -309,10 +309,9 @@ export default function BuildOutputTable({
|
||||
allocated += allocation.quantity;
|
||||
});
|
||||
|
||||
if (
|
||||
item.bom_item_detail?.quantity &&
|
||||
allocated >= item.bom_item_detail.quantity
|
||||
) {
|
||||
const required = bomUnitQuantity(item.bom_item_detail);
|
||||
|
||||
if (required && allocated >= required) {
|
||||
fullyAllocatedCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user