Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions docs/docs/manufacturing/bom.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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 × 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 × piece_count.',
validators=[django.core.validators.MinValueValidator(1)],
verbose_name='Piece Count',
),
),
]
22 changes: 20 additions & 2 deletions src/backend/InvenTree/part/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3659,6 +3659,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)
Expand Down Expand Up @@ -3984,6 +3986,16 @@ def check_part_lock(self, assembly):
),
)

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,
Expand Down Expand Up @@ -4038,6 +4050,7 @@ def hash_fields(self) -> list[str]:
'setup_quantity',
'attrition',
'rounding_multiple',
'piece_count',
'reference',
'optional',
'inherited',
Expand Down Expand Up @@ -4200,9 +4213,14 @@ def get_required_quantity(self, build_quantity: float) -> float:

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:
Expand Down
11 changes: 11 additions & 0 deletions src/backend/InvenTree/part/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1666,6 +1666,7 @@ class Meta:
'reference',
'raw_amount',
'quantity',
'piece_count',
'allow_variants',
'inherited',
'optional',
Expand Down Expand Up @@ -1715,6 +1716,16 @@ class Meta:
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 × piece_count.'
),
)

part = serializers.PrimaryKeyRelatedField(
queryset=Part.objects.filter(assembly=True),
label=_('Assembly'),
Expand Down
60 changes: 60 additions & 0 deletions src/backend/InvenTree/part/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3461,6 +3461,66 @@ def test_can_build(self):
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."""
Expand Down
109 changes: 109 additions & 0 deletions src/backend/InvenTree/part/test_bom_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -595,3 +595,112 @@ def validate(valid: bool = True):
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.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.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.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.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.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
4 changes: 4 additions & 0 deletions src/frontend/src/forms/BomForms.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
Expand Down
13 changes: 13 additions & 0 deletions src/frontend/src/tables/bom/BomTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Text size='xs'>{piece_count}</Text>;
}
}
},
{
accessor: 'substitutes',
defaultVisible: false,
Expand Down
Loading