diff --git a/CHANGELOG.md b/CHANGELOG.md index 16a807a3a3..f6631a6706 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- [#12422](https://github.com/inventree/InvenTree/pull/12422) adds a "piece_count" field to the BomItem model, for representing cut-to-length parts. The total material required is calculated as `quantity x piece_count`. - Adds configurable default for merging purchase order line items via the `PURCHASEORDER_MERGE_LINE_ITEMS` global setting - [#12393](https://github.com/inventree/InvenTree/pull/12393) adds "discount" attribute to order line items, allowing users to specify a discount for each line item on an order. The discount can be specified as either a percentage or a fixed amount, and is applied to the line item total when calculating the order total. - [#12391](https://github.com/inventree/InvenTree/pull/12391) adds facility for bulk deleting line items against orders diff --git a/docs/docs/manufacturing/bom.md b/docs/docs/manufacturing/bom.md index 76801e65ab..e69d4b6195 100644 --- a/docs/docs/manufacturing/bom.md +++ b/docs/docs/manufacturing/bom.md @@ -22,6 +22,7 @@ A BOM for a particular assembly is comprised of a number (zero or more) of BOM " | Quantity | The quantity of *Part* required for the assembly - this value is automatically calculated from the "raw amount" field, taking into account the units of measure associated with the underlying part. | | 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 | +| Piece Count | The number of individual pieces required per assembly (for cut-to-length items). Defaults to 1. Total material = quantity x piece_count. | | 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. | @@ -40,6 +41,45 @@ If the underlying part does not have a defined unit of measure, the `raw_amount` The `raw_amount` field also allows for fractional representation of the required quantity. For example, if the required quantity is 0.5 kg, the user can specify this as `500 g`, `0.5 kg`, `1/2 kg`, etc. The `quantity` field will be automatically calculated as 0.5 kg, regardless of the specific representation used in the `raw_amount` field. +### Piece Count (Cut-to-Length Parts) + +The `piece_count` field supports scenarios where a material is cut or divided into multiple identical pieces for each assembly. This is common for cables, wires, tubing, extrusions, and similar length-based materials. + +When `piece_count` is greater than 1, the `quantity` field represents the size or length of each individual piece, and `piece_count` indicates how many such pieces are needed per assembly. The total material required is calculated as: + +``` +Total material = quantity x piece_count x build_quantity +``` + +#### Example: Wire Harness Assembly + +Consider an assembly that requires 10 pieces of wire, each cut to 200mm length: + +| Field | Value | Description | +| --- | --- | --- | +| Quantity | 200 (mm) | Length of each individual wire piece | +| Piece Count | 10 | Number of wire pieces per assembly | +| Build Quantity | 5 | Number of assemblies to build | +| **Total Required** | **10,000 mm (10m)** | 200 x 10 x 5 = 10,000 mm | + +#### Example: Tubing for Hydraulic System + +An assembly requires 4 pieces of tubing, each 500mm long, with 5% attrition: + +| Field | Value | Description | +| --- | --- | --- | +| Quantity | 500 (mm) | Length of each tube section | +| Piece Count | 4 | Number of tube pieces per assembly | +| Build Quantity | 3 | Number of assemblies to build | +| Attrition | 5% | Account for cutting waste | +| **Total Required** | **6,300 mm (6.3m)** | (500 x 4 x 3) x 1.05 = 6,300 mm | + +!!! 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. + +!!! 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. + ### Consumable BOM Line Items If a BOM line item is marked as *consumable*, this means that while the part and quantity information is tracked in the BOM, this line item does not get allocated to a [Build Order](./build.md). This may be useful for certain items that the user does not wish to track through the build process, as they may be low value, in abundant stock, or otherwise complicated to track. @@ -165,6 +205,7 @@ The following BOM item fields are used when calculating the BOM checksum: - *Attrition* - The attrition percentage of the BOM line item. - *Setup Quantity* - The setup quantity of the BOM line item. - *Rounding Multiple* - The rounding multiple of the BOM line item. +- *Piece Count* - The number of pieces required per assembly (for cut-to-length items). - *Consumable* - Whether the BOM line item is consumable. - *Inherited* - Whether the BOM line item is inherited. - *Optional* - Whether the BOM line item is optional. diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index bf324d275c..0da8a25fa1 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 531 +INVENTREE_API_VERSION = 532 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v532 -> 2026-08-15 : https://github.com/inventree/InvenTree/pull/12422 + - Adds "piece_count" field to the BomItem model and API endpoints (for cut-to-length parts) + v531 -> 2026-08-13 : https://github.com/inventree/InvenTree/pull/12635 - Adds "bulk update" support for the ReturnOrderLineItem API endpoint diff --git a/src/backend/InvenTree/part/migrations/0153_bomitem_piece_count_bomitem_piece_size.py b/src/backend/InvenTree/part/migrations/0153_bomitem_piece_count_bomitem_piece_size.py new file mode 100644 index 0000000000..646b8b73bb --- /dev/null +++ b/src/backend/InvenTree/part/migrations/0153_bomitem_piece_count_bomitem_piece_size.py @@ -0,0 +1,30 @@ +"""Add piece_count field to BomItem model. + +This field supports cut-to-length parts (cables, tubing, profiles) where +a BOM line requires multiple pieces of a specific size. The existing +quantity field represents the per-piece size/length, and piece_count +indicates how many pieces are needed. Total material = quantity x piece_count. +""" + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0152_alter_partpricing_currency'), + ] + + operations = [ + migrations.AddField( + model_name='bomitem', + name='piece_count', + field=models.PositiveIntegerField( + default=1, + help_text='Number of pieces required (for cut-to-length items). Total material = quantity x piece_count.', + validators=[django.core.validators.MinValueValidator(1)], + verbose_name='Piece Count', + ), + ), + ] diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index e1d5ec288e..ec28a8827e 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -3690,6 +3690,8 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel): 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 + piece_count: Number of pieces required (for cut-to-length items like cables, tubing). + Total material = quantity x piece_count. note: Note field for this BOM item checksum: Validation checksum for the particular BOM line item validated: Boolean field indicating if this BOM item is valid (checksum matches) @@ -4015,6 +4017,16 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel): ), ) + piece_count = models.PositiveIntegerField( + default=1, + validators=[MinValueValidator(1)], + verbose_name=_('Piece Count'), + help_text=_( + 'Number of pieces required (for cut-to-length items). ' + 'Total material = quantity x piece_count.' + ), + ) + reference = models.CharField( max_length=5000, blank=True, @@ -4069,6 +4081,7 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel): 'setup_quantity', 'attrition', 'rounding_multiple', + 'piece_count', 'reference', 'optional', 'inherited', @@ -4231,9 +4244,14 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel): Returns: Production quantity required for this component + + Note: + For cut-to-length parts, quantity represents the per-piece size/length + and piece_count indicates how many pieces are needed. + Total material = quantity x piece_count x build_quantity. """ - # Base quantity requirement - required = self.quantity * build_quantity + # Base quantity requirement (quantity is per-piece, piece_count is number of pieces) + required = self.quantity * self.piece_count * build_quantity # Account for attrition if self.attrition > 0: diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index df1a26e6a6..2a14c73368 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -1666,6 +1666,7 @@ class BomItemSerializer( 'reference', 'raw_amount', 'quantity', + 'piece_count', 'allow_variants', 'inherited', 'optional', @@ -1715,6 +1716,16 @@ class BomItemSerializer( required=False, allow_null=True ) + piece_count = serializers.IntegerField( + required=False, + default=1, + label=_('Piece Count'), + help_text=_( + 'Number of pieces required (for cut-to-length items). ' + 'Total material = quantity x piece_count.' + ), + ) + part = serializers.PrimaryKeyRelatedField( queryset=Part.objects.filter(assembly=True), label=_('Assembly'), diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py index ed2e5fff42..837bd68086 100644 --- a/src/backend/InvenTree/part/test_api.py +++ b/src/backend/InvenTree/part/test_api.py @@ -3509,6 +3509,66 @@ class BomItemTest(InvenTreeAPITestCase): can_build = response.data['can_build'] self.assertAlmostEqual(can_build, 482.9, places=1) + def test_piece_count_get(self): + """Test that piece_count is returned in GET response for BomItem.""" + bom_item = BomItem.objects.first() + assert bom_item + + url = reverse('api-bom-item-detail', kwargs={'pk': bom_item.pk}) + response = self.get(url, expected_code=200) + + # piece_count should be present in the response + self.assertIn('piece_count', response.data) + # Default value is 1 + self.assertEqual(response.data['piece_count'], 1) + + def test_piece_count_post(self): + """Test creating a BomItem with piece_count via POST.""" + url = reverse('api-bom-list') + + # Create a BomItem with piece_count specified + data = {'part': 100, 'sub_part': 4, 'quantity': 200, 'piece_count': 10} + response = self.post(url, data, expected_code=201) + + self.assertEqual(response.data['piece_count'], 10) + self.assertEqual(response.data['quantity'], 200) + + def test_piece_count_post_default(self): + """Test that piece_count defaults to 1 when not specified in POST.""" + url = reverse('api-bom-list') + + data = {'part': 100, 'sub_part': 4, 'quantity': 50} + response = self.post(url, data, expected_code=201) + + self.assertEqual(response.data['piece_count'], 1) + + def test_piece_count_patch(self): + """Test updating piece_count via PATCH.""" + bom_item = BomItem.objects.first() + assert bom_item + + url = reverse('api-bom-item-detail', kwargs={'pk': bom_item.pk}) + + # Update piece_count + response = self.patch(url, {'piece_count': 7}, expected_code=200) + self.assertEqual(response.data['piece_count'], 7) + + # Verify the change persisted + response = self.get(url, expected_code=200) + self.assertEqual(response.data['piece_count'], 7) + + def test_piece_count_invalid_values(self): + """Test that invalid piece_count values are rejected via API.""" + url = reverse('api-bom-list') + + # piece_count = 0 should be rejected + data = {'part': 100, 'sub_part': 4, 'quantity': 10, 'piece_count': 0} + self.post(url, data, expected_code=400) + + # piece_count = -1 should be rejected + data = {'part': 100, 'sub_part': 4, 'quantity': 10, 'piece_count': -1} + self.post(url, data, expected_code=400) + class AttachmentTest(InvenTreeAPITestCase): """Unit tests for the Attachment API endpoint.""" diff --git a/src/backend/InvenTree/part/test_bom_item.py b/src/backend/InvenTree/part/test_bom_item.py index 295c6c7027..3e08bea0a9 100644 --- a/src/backend/InvenTree/part/test_bom_item.py +++ b/src/backend/InvenTree/part/test_bom_item.py @@ -595,3 +595,112 @@ class BomItemTest(TestCase): check(valid=False) self.assertIsNotNone(assembly.bom_checked_date) + + def test_piece_count_default(self): + """Test that piece_count defaults to 1 and does not change existing behavior.""" + item = BomItem.objects.get(part=100, sub_part=50) + + # Default value should be 1 + self.assertEqual(item.piece_count, 1) + + # With piece_count=1, get_required_quantity should behave as before + item.set_quantity(10) + item.attrition = 0 + item.setup_quantity = 0 + item.rounding_multiple = None + item.save() + + # 10 * 1 (piece_count) * 5 (build_quantity) = 50 + self.assertEqual(item.get_required_quantity(5), 50) + + def test_piece_count_multiplier(self): + """Test that piece_count correctly multiplies the required quantity. + + Example: Cutting wire into 200mm lengths, need 10 pieces per assembly. + quantity=200 (mm per piece), piece_count=10, build_quantity=5 + Total = 200 * 10 * 5 = 10000 mm + """ + item = BomItem.objects.get(part=100, sub_part=50) + + item.set_quantity(200) + item.piece_count = 10 + item.attrition = 0 + item.setup_quantity = 0 + item.rounding_multiple = None + item.save() + + # 200 * 10 * 5 = 10000 + self.assertEqual(item.get_required_quantity(5), 10000) + + # 200 * 10 * 1 = 2000 + self.assertEqual(item.get_required_quantity(1), 2000) + + # 200 * 10 * 10 = 20000 + self.assertEqual(item.get_required_quantity(10), 20000) + + def test_piece_count_with_attrition(self): + """Test piece_count combined with attrition percentage.""" + item = BomItem.objects.get(part=100, sub_part=50) + + item.set_quantity(100) + item.piece_count = 5 + item.attrition = 10 # 10% attrition + item.setup_quantity = 0 + item.rounding_multiple = None + item.save() + + # Base: 100 * 5 * 2 = 1000 + # With 10% attrition: 1000 * 1.10 = 1100 + self.assertEqual(item.get_required_quantity(2), 1100) + + def test_piece_count_with_setup_quantity(self): + """Test piece_count combined with setup_quantity.""" + item = BomItem.objects.get(part=100, sub_part=50) + + item.set_quantity(50) + item.piece_count = 4 + item.attrition = 0 + item.setup_quantity = 20 + item.rounding_multiple = None + item.save() + + # Base: 50 * 4 * 3 = 600 + # With setup_quantity: 600 + 20 = 620 + self.assertEqual(item.get_required_quantity(3), 620) + + def test_piece_count_with_rounding(self): + """Test piece_count combined with rounding_multiple.""" + item = BomItem.objects.get(part=100, sub_part=50) + + item.set_quantity(7) + item.piece_count = 3 + item.attrition = 0 + item.setup_quantity = 0 + item.rounding_multiple = 25 + item.save() + + # Base: 7 * 3 * 2 = 42 + # Rounded up to nearest multiple of 25: 50 + self.assertEqual(item.get_required_quantity(2), 50) + + def test_piece_count_validation(self): + """Test that piece_count rejects invalid values (0, negative).""" + item = BomItem.objects.get(part=100, sub_part=50) + + # piece_count = 0 should be rejected (MinValueValidator(1)) + item.piece_count = 0 + with self.assertRaises(django_exceptions.ValidationError): + item.full_clean() + + # piece_count = -1 should also be rejected + item.piece_count = -1 + with self.assertRaises(django_exceptions.ValidationError): + item.full_clean() + + # piece_count = 1 is the minimum valid value + item.piece_count = 1 + item.full_clean() # Should not raise + + # piece_count = 100 is a valid value + item.piece_count = 100 + item.full_clean() # Should not raise diff --git a/src/frontend/src/forms/BomForms.tsx b/src/frontend/src/forms/BomForms.tsx index 3f93a830d7..3a3d346b7d 100644 --- a/src/frontend/src/forms/BomForms.tsx +++ b/src/frontend/src/forms/BomForms.tsx @@ -42,6 +42,10 @@ export function bomItemFields({ label: t`Quantity`, description: t`Required component quantity` }, + piece_count: { + label: t`Piece Count`, + description: t`Number of pieces required (for cut-to-length items). Total material = quantity × piece_count.` + }, reference: {}, setup_quantity: {}, attrition: {}, diff --git a/src/frontend/src/tables/bom/BomTable.tsx b/src/frontend/src/tables/bom/BomTable.tsx index fb38da1041..fc562fb3a2 100644 --- a/src/frontend/src/tables/bom/BomTable.tsx +++ b/src/frontend/src/tables/bom/BomTable.tsx @@ -250,6 +250,19 @@ export function BomTable({ } } }, + { + accessor: 'piece_count', + defaultVisible: false, + sortable: true, + render: (record: any) => { + const piece_count = record.piece_count; + if (piece_count == null || piece_count <= 1) { + return '-'; + } else { + return {piece_count}; + } + } + }, { accessor: 'substitutes', defaultVisible: false,