mirror of
https://github.com/inventree/InvenTree.git
synced 2026-08-20 20:19:44 +00:00
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:
co-authored by
Aman Jain
parent
e4b23b4665
commit
ee4ad7fd10
@@ -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',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user