feat: add piece_count and piece_size fields to BomItem for cut-to-length parts (#12422)

* feat: add piece_count and piece_size fields to BomItem for cut-to-length parts

Manufacturing BOMs frequently require multiple pieces of a specific size
cut from continuous stock (cables, tubing, structural profiles). Currently
the only way to express "10 pieces of 250mm cable" is to enter the total
length (2.5m) as quantity, which loses the piece-count information that
purchasing and production need.

This adds two optional fields to BomItem:
- piece_count: number of discrete pieces required (default: 1)
- piece_size: size/length of each piece (e.g. "250 mm")

When piece_size is specified, the total quantity is auto-calculated as
piece_count × piece_size, maintaining full backward compatibility (existing
items effectively have piece_count=1 and empty piece_size).

Changes:
- Backend: new model fields, migration, updated recalculate_quantity()
  logic, hash_fields for BOM validation
- API: serializer exposes piece_count and piece_size
- Frontend: BOM form includes the new fields, BOM table shows them as
  optional columns

Addresses #10274

* refactor: simplify to single piece_count field per reviewer feedback

Remove the piece_size field entirely. The existing quantity field already
represents the per-piece size/length, so piece_count multiplied by
quantity gives the total material requirement.

Example: quantity=200mm, piece_count=10 → total 2m of wire in 10 pieces.

Changes:
- Remove piece_size model field, serializer field, and frontend column/form
- Update migration to only add piece_count
- Update get_required_quantity() to multiply by piece_count
- Restore original recalculate_quantity() without piece_size logic

* test/docs: add unit tests and documentation for piece_count field

* style: replace ambiguous × with x to fix RUF002 lint error

* Address review feedback: api_version bump, changelog, style fix

- Bump INVENTREE_API_VERSION to 531 with entry for piece_count field
- Add CHANGELOG.md entry under Unreleased > Added
- Fix RUF001: replace ambiguous × with x in serializers.py help_text

* fix: align piece_count migration help_text with model (RUF001)

The 0153 AddField recorded help_text with a Unicode multiplication sign
(×), while the model field uses plain 'x' after the RUF001 fix. This
mismatch made makemigrations --check flag an unstaged
0154_alter_bomitem_piece_count migration, failing the DB test CI jobs.

Update the original migration's help_text (and docstring) to plain 'x'
so the field definition matches the model, keeping a single clean
migration instead of add-then-alter.

* fix: use set_quantity() in piece_count tests

BomItem.quantity is a derived field, recalculated from raw_amount on
every save() via recalculate_quantity(). Setting item.quantity directly
was overwritten back to the fixture value on save, so the tests computed
against quantity=3 and failed. Use set_quantity() (which sets raw_amount)
to match how quantity is meant to be updated.

* ci: re-trigger CI to confirm Firefox E2E failures are transient

---------

Co-authored-by: Aman Jain <jainamn@amazon.com>
This commit is contained in:
amanjain57-gif
2026-08-16 09:43:42 +10:00
committed by GitHub
co-authored by Aman Jain
parent e4b23b4665
commit ee4ad7fd10
10 changed files with 293 additions and 3 deletions
+1
View File
@@ -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
+41
View File
@@ -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.
@@ -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
@@ -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',
),
),
]
+20 -2
View File
@@ -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:
+11
View File
@@ -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'),
+60
View File
@@ -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."""
+109
View File
@@ -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
+4
View File
@@ -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: {},
+13
View File
@@ -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,