diff --git a/docs/docs/manufacturing/bom.md b/docs/docs/manufacturing/bom.md index a8c6162521..a02dc21f99 100644 --- a/docs/docs/manufacturing/bom.md +++ b/docs/docs/manufacturing/bom.md @@ -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 diff --git a/docs/docs/manufacturing/required.md b/docs/docs/manufacturing/required.md index 949277b9ab..f39a00e830 100644 --- a/docs/docs/manufacturing/required.md +++ b/docs/docs/manufacturing/required.md @@ -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 diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index f1c0d96927..241a3a3ac9 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index bb55391e96..8b4ab9b3dd 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -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', diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index 1ade9f3045..e8c0bcc4f0 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -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 diff --git a/src/backend/InvenTree/build/tasks.py b/src/backend/InvenTree/build/tasks.py index 612c056c77..588b36c4b1 100644 --- a/src/backend/InvenTree/build/tasks.py +++ b/src/backend/InvenTree/build/tasks.py @@ -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 diff --git a/src/backend/InvenTree/build/test_api.py b/src/backend/InvenTree/build/test_api.py index eaf7c0b435..2bd0b33abc 100644 --- a/src/backend/InvenTree/build/test_api.py +++ b/src/backend/InvenTree/build/test_api.py @@ -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.""" diff --git a/src/backend/InvenTree/build/test_build.py b/src/backend/InvenTree/build/test_build.py index 7075db8e3f..b1e84e1e58 100644 --- a/src/backend/InvenTree/build/test_build.py +++ b/src/backend/InvenTree/build/test_build.py @@ -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) diff --git a/src/backend/InvenTree/part/filters.py b/src/backend/InvenTree/part/filters.py index 2c832c2e9e..9bac7507aa 100644 --- a/src/backend/InvenTree/part/filters.py +++ b/src/backend/InvenTree/part/filters.py @@ -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(), diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index 1f15ed36bb..11eae3b2d2 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -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) diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index ec6c2741a6..d13ea6a691 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -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(), ), ) diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py index 133c8fd6bd..8be813183b 100644 --- a/src/backend/InvenTree/part/test_api.py +++ b/src/backend/InvenTree/part/test_api.py @@ -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() diff --git a/src/backend/InvenTree/part/test_bom_item.py b/src/backend/InvenTree/part/test_bom_item.py index 4b37f90af6..9f340a1506 100644 --- a/src/backend/InvenTree/part/test_bom_item.py +++ b/src/backend/InvenTree/part/test_bom_item.py @@ -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) diff --git a/src/backend/InvenTree/plugin/builtin/exporter/bom_exporter.py b/src/backend/InvenTree/plugin/builtin/exporter/bom_exporter.py index fd5efcbaab..43f3f0091e 100644 --- a/src/backend/InvenTree/plugin/builtin/exporter/bom_exporter.py +++ b/src/backend/InvenTree/plugin/builtin/exporter/bom_exporter.py @@ -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, ) diff --git a/src/backend/InvenTree/pricing/models.py b/src/backend/InvenTree/pricing/models.py index 21a5170c7a..611c3c4b11 100644 --- a/src/backend/InvenTree/pricing/models.py +++ b/src/backend/InvenTree/pricing/models.py @@ -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 diff --git a/src/backend/InvenTree/pricing/test_pricing.py b/src/backend/InvenTree/pricing/test_pricing.py index 9968300836..a194bfb624 100644 --- a/src/backend/InvenTree/pricing/test_pricing.py +++ b/src/backend/InvenTree/pricing/test_pricing.py @@ -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.""" diff --git a/src/frontend/src/tables/bom/BomTable.tsx b/src/frontend/src/tables/bom/BomTable.tsx index 51c24a1bee..36407e42be 100644 --- a/src/frontend/src/tables/bom/BomTable.tsx +++ b/src/frontend/src/tables/bom/BomTable.tsx @@ -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]); diff --git a/src/frontend/src/tables/build/BuildLineTable.tsx b/src/frontend/src/tables/build/BuildLineTable.tsx index b330967a02..ed939bf798 100644 --- a/src/frontend/src/tables/build/BuildLineTable.tsx +++ b/src/frontend/src/tables/build/BuildLineTable.tsx @@ -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 ( - {record.bom_item_detail?.quantity} + + {formatDecimal(bomUnitQuantity(record.bom_item_detail))} + {record?.part_detail?.units && ( [{record.part_detail.units}] )} @@ -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) { diff --git a/src/frontend/src/tables/build/BuildOutputTable.tsx b/src/frontend/src/tables/build/BuildOutputTable.tsx index c806ac113d..06c786125d 100644 --- a/src/frontend/src/tables/build/BuildOutputTable.tsx +++ b/src/frontend/src/tables/build/BuildOutputTable.tsx @@ -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; } });