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,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,