[bug] Can build fix (#13090)

* Fix 'can_build' calculation

- Take 'piece_count' into consideration
- Update queryset annotations
- Update quantity calculation call sites

* Updated docs
This commit is contained in:
Oliver authored and GitHub committed 2026-10-24 17:28:07 +11:00
1 parent 66e3278ea7
commit 43956b9324
19 files changed
+368 -25

No files matched your search

+4 -1
View File
@@ -77,6 +77,9 @@ An assembly requires 4 pieces of tubing, each 500mm long, with 5% attrition:
!!! 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.
!!! info "Where Piece Count Applies"
The total material per assembly (`quantity x piece_count`) is used wherever InvenTree calculates component requirements for an assembly, including the [required build quantity](./required.md), the *Can Build* quantity, [BOM pricing](../part/pricing.md#bom-pricing), and the *Total Quantity* column of the [BOM Exporter](../plugins/builtin/bom_exporter.md).
!!! 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.
@@ -216,7 +219,7 @@ In the example above, the *Doohickey* sub-assembly is required three times for e
Other columns (such as *Available Stock*) display the same values as they would in the BOM for the sub-assembly itself.
!!! info "Setup Quantity and Attrition"
The calculated quantity values are based on the line item quantities only. The [setup quantity](#bom-line-items) and [attrition](#bom-line-items) of any upstream sub-assemblies are not included in the calculation.
The calculated quantity values are based on the line item quantities only. The quantity of each upstream sub-assembly includes its [piece count](#piece-count-cut-to-length-parts) (`quantity x piece_count`). The [setup quantity](#bom-line-items) and [attrition](#bom-line-items) of any upstream sub-assemblies are not included in the calculation.
#### Behavior
+14 -2
View File
@@ -14,6 +14,17 @@ The base quantity of a BOM line item is defined by the `Quantity` field of the B
Required Quantity = Base Quantity * Number of Assemblies
```
### Piece Count
The `Piece Count` field of a BOM line item is used for [cut-to-length parts](./bom.md#piece-count-cut-to-length-parts), where the `Quantity` field represents the size (e.g. length) of each individual piece. The base quantity is multiplied by the piece count to determine the total material required for each assembly.
```
Required Quantity = Base Quantity * Piece Count * Number of Assemblies
```
!!! info "Optional"
The piece count is optional. If not specified, it defaults to 1.
### Attrition
The `Attrition` field of a BOM line item is used to account for expected losses during the production process. This is expressed as a percentage of the `Base Quantity` (e.g. 2%).
@@ -54,6 +65,7 @@ Required Quantity = ceil(Required Quantity / Rounding Multiple) * Rounding Multi
Consider a BOM line item with the following properties:
- Base Quantity: 3
- Piece Count: 1
- Attrition: 2% (0.02)
- Setup Quantity: 10
- Rounding Multiple: 25
@@ -61,8 +73,8 @@ Consider a BOM line item with the following properties:
If we are building 100 assemblies, the required quantity would be calculated as follows:
```
Required Quantity = Base Quantity * Number of Assemblies
= 3 * 100
Required Quantity = Base Quantity * Piece Count * Number of Assemblies
= 3 * 1 * 100
= 300
Attrition Value = Required Quantity * Attrition Percentage
@@ -1,11 +1,15 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 563
INVENTREE_API_VERSION = 564
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v564 -> 2026-10-24 : https://github.com/inventree/InvenTree/pull/xxxxx
- BomItem "can_build", "pricing_min_total" and "pricing_max_total" fields now account for "piece_count"
- BuildLine "unit_quantity" ordering now accounts for the BOM "piece_count"
v563 -> 2026-10-22 : https://github.com/inventree/InvenTree/pull/13030
- Adds optional 'link_nonce' mechanism to the API root
+18 -3
View File
@@ -3,7 +3,15 @@
from __future__ import annotations
from django.contrib.auth.models import User
from django.db.models import DecimalField, F, OuterRef, Q, Subquery, Sum
from django.db.models import (
DecimalField,
ExpressionWrapper,
F,
OuterRef,
Q,
Subquery,
Sum,
)
from django.db.models.functions import Coalesce
from django.urls import include, path
from django.utils.translation import gettext_lazy as _
@@ -614,10 +622,18 @@ class BuildLineMixin(SerializerContextMixin):
source_build = self.source_build
return build.serializers.BuildLineSerializer.annotate_queryset(
queryset = build.serializers.BuildLineSerializer.annotate_queryset(
queryset, build=source_build
)
# Quantity required per assembly (used for ordering)
return queryset.alias(
unit_quantity=ExpressionWrapper(
F('bom_item__quantity') * F('bom_item__piece_count'),
output_field=DecimalField(),
)
)
class BuildLineOutputOptions(OutputConfiguration):
"""Output options for BuildLine endpoint."""
@@ -682,7 +698,6 @@ class BuildLineList(
'part': 'bom_item__sub_part__name',
'IPN': 'bom_item__sub_part__IPN',
'reference': 'bom_item__reference',
'unit_quantity': 'bom_item__quantity',
'category': 'bom_item__sub_part__category__name',
'consumable': 'bom_item__consumable',
'optional': 'bom_item__optional',
+1 -1
View File
@@ -2056,7 +2056,7 @@ class Build(
)
# The amount allocated against an output must at least equal the BOM quantity
if allocated['q'] < line.bom_item.quantity:
if allocated['q'] < line.bom_item.unit_quantity:
return False
# At this stage, we can assume that the output is fully allocated
+1 -1
View File
@@ -489,7 +489,7 @@ def check_build_stock(build):
available = max(0, in_stock - allocated)
required = Decimal(bom_item.quantity) * Decimal(build.quantity)
required = bom_item.unit_quantity * Decimal(build.quantity)
if available < required:
# There is not sufficient stock for this part
+37
View File
@@ -2280,6 +2280,43 @@ class BuildLineTests(BuildAPITest):
line_data = next(item for item in response.data if item['pk'] == line.pk)
self.assertEqual(line_data['allocated'], 1)
def test_ordering_unit_quantity_piece_count(self):
"""Ordering by 'unit_quantity' must account for the BOM piece_count."""
assembly = Part.objects.create(
name='Unit Quantity Assembly',
description='Assembly for unit quantity ordering',
assembly=True,
)
wire = Part.objects.create(
name='Wire', description='Cut-to-length wire', component=True
)
screw = Part.objects.create(name='Screw', description='A screw', component=True)
# 2 x 5 = 10 units of wire per assembly
BomItem.objects.create(part=assembly, sub_part=wire, quantity=2, piece_count=5)
# 3 x 1 = 3 screws per assembly
BomItem.objects.create(part=assembly, sub_part=screw, quantity=3)
build = Build.objects.create(
part=assembly, reference='BO-9998', quantity=1, title='Unit quantity'
)
url = reverse('api-build-line-list')
response = self.get(
url, {'build': build.pk, 'ordering': 'unit_quantity'}, expected_code=200
)
self.assertEqual([line['part'] for line in response.data], [screw.pk, wire.pk])
response = self.get(
url, {'build': build.pk, 'ordering': '-unit_quantity'}, expected_code=200
)
self.assertEqual([line['part'] for line in response.data], [wire.pk, screw.pk])
class BuildConsumeTest(BuildAPITest):
"""Test consuming allocated stock."""
+40
View File
@@ -20,6 +20,7 @@ from django_q.models import OrmQ
import build.tasks
import common.models
import common.notifications
import company.models
from build.models import Build, BuildItem, BuildLine, generate_next_build_reference
from build.status_codes import BuildStatus
@@ -442,6 +443,45 @@ class BuildTest(BuildTestBase):
self.assertTrue(self.build.is_fully_allocated(tracked=False))
def test_output_allocation_piece_count(self):
"""Tracked output allocation must account for the BOM piece_count."""
# 2 x 3 = 6 units of sub_part_3 required per output
self.bom_item_3.piece_count = 3
self.bom_item_3.save()
# Allocating the bare BOM quantity is not sufficient
self.allocate_stock(self.output_1, {self.stock_3_1: 2})
self.assertFalse(self.build.is_output_fully_allocated(self.output_1))
BuildItem.objects.filter(install_into=self.output_1).delete()
self.allocate_stock(self.output_1, {self.stock_3_1: 6})
self.assertTrue(self.build.is_output_fully_allocated(self.output_1))
def test_check_build_stock_piece_count(self):
"""The 'stock required' notification must account for the BOM piece_count."""
def required_lines():
with mock.patch.object(
common.notifications, 'trigger_notification'
) as notify:
build.tasks.check_build_stock(self.build)
if not notify.called:
return {}
context = notify.call_args.kwargs['context']
return {line['part'].pk: line['required'] for line in context['lines']}
# 5 x 10 = 50 required, 103 available
self.assertNotIn(self.sub_part_1.pk, required_lines())
# 5 x 3 x 10 = 150 required, 103 available
self.bom_item_1.piece_count = 3
self.bom_item_1.save()
self.assertEqual(required_lines()[self.sub_part_1.pk], 150)
def test_overallocation_and_trim(self):
"""Test overallocation of stock and trim function."""
self.assertEqual(self.build.status, status.BuildStatus.PENDING)
+5 -1
View File
@@ -553,7 +553,11 @@ def annotate_bom_item_can_build(queryset: QuerySet, reference: str = '') -> Quer
Case(
When(Q(quantity=0), then=Value(0)),
default=(F('total_stock') - F('setup_quantity'))
/ (F('quantity') * (1.0 + F('attrition') / 100.0)),
/ (
F('quantity')
* F('piece_count')
* (1.0 + F('attrition') / 100.0)
),
output_field=FloatField(),
),
output_field=FloatField(),
+10 -1
View File
@@ -3446,6 +3446,15 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
except Part.DoesNotExist:
raise ValidationError({'sub_part': _('Sub part must be specified')})
@property
def unit_quantity(self) -> Decimal:
"""Return the quantity of sub_part required to produce a single assembly.
This accounts for the piece_count (total material = quantity x piece_count),
but does not include attrition, setup quantity or rounding.
"""
return Decimal(self.quantity) * Decimal(self.piece_count)
def can_build_quantity(self, available_stock: float) -> int:
"""Calculate the number of assemblies that can be built with the available stock.
@@ -3458,7 +3467,7 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
"""
# Account for setup quantity
available_stock = Decimal(max(0, available_stock - self.setup_quantity))
quantity_decimal = Decimal(self.quantity)
quantity_decimal = self.unit_quantity
attrition_decimal = Decimal(self.attrition) / 100
n = quantity_decimal * (1 + attrition_decimal)
+6 -2
View File
@@ -1674,11 +1674,15 @@ class BomItemSerializer(
# Annotate with the 'total pricing' information based on unit pricing and quantity
queryset = queryset.annotate(
pricing_min_total=ExpressionWrapper(
F('quantity') * F('sub_part__pricing_data__overall_min'),
F('quantity')
* F('piece_count')
* F('sub_part__pricing_data__overall_min'),
output_field=models.DecimalField(),
),
pricing_max_total=ExpressionWrapper(
F('quantity') * F('sub_part__pricing_data__overall_max'),
F('quantity')
* F('piece_count')
* F('sub_part__pricing_data__overall_max'),
output_field=models.DecimalField(),
),
)
+108
View File
@@ -12,6 +12,7 @@ from django.test.utils import CaptureQueriesContext, override_settings
from django.urls import reverse
import pytest
from djmoney.money import Money
from PIL import Image
from rest_framework.test import APIClient
@@ -3481,6 +3482,113 @@ class BomItemTest(InvenTreeAPITestCase):
can_build = response.data['can_build']
self.assertAlmostEqual(can_build, 482.9, places=1)
def test_piece_count_can_build(self):
"""Test that the 'can_build' annotation accounts for piece_count."""
assembly = Part.objects.create(
name='Cable Assembly', description='A cable assembly', assembly=True
)
wire = Part.objects.create(
name='Wire', description='Cut-to-length wire', component=True
)
bom_item = BomItem.objects.create(
part=assembly, sub_part=wire, quantity=2, piece_count=5
)
StockItem.objects.create(part=wire, quantity=20)
response = self.get(
reverse('api-bom-item-detail', kwargs={'pk': bom_item.pk}),
expected_code=200,
)
# 20 / (2 * 5) = 2
self.assertEqual(response.data['piece_count'], 5)
self.assertAlmostEqual(response.data['can_build'], 2)
# The part requirements endpoint also accounts for piece_count
response = self.get(
reverse('api-part-requirements', kwargs={'pk': assembly.pk}),
expected_code=200,
)
self.assertEqual(response.data['can_build'], 2)
def test_piece_count_pricing(self):
"""Test that BOM pricing totals account for piece_count."""
assembly = Part.objects.create(
name='Cable Assembly', description='A cable assembly', assembly=True
)
wire = Part.objects.create(
name='Wire', description='Cut-to-length wire', component=True
)
pricing = wire.pricing
pricing.internal_cost_min = Money(2, 'USD')
pricing.internal_cost_max = Money(3, 'USD')
pricing.save()
bom_item = BomItem.objects.create(
part=assembly, sub_part=wire, quantity=4, piece_count=5
)
self.assignRole('pricing.view')
response = self.get(
reverse('api-bom-item-detail', kwargs={'pk': bom_item.pk}),
{'pricing': True},
expected_code=200,
)
# 4 x 5 = 20 units required per assembly
self.assertAlmostEqual(float(response.data['pricing_min_total']), 40)
self.assertAlmostEqual(float(response.data['pricing_max_total']), 60)
def test_piece_count_bom_export(self):
"""Test that the BOM exporter total quantity accounts for piece_count."""
assembly = Part.objects.create(
name='Top Assembly', description='Top level assembly', assembly=True
)
sub_assembly = Part.objects.create(
name='Sub Assembly',
description='A sub-assembly',
assembly=True,
component=True,
)
wire = Part.objects.create(
name='Wire', description='Cut-to-length wire', component=True
)
BomItem.objects.create(
part=assembly, sub_part=sub_assembly, quantity=2, piece_count=3
)
BomItem.objects.create(
part=sub_assembly, sub_part=wire, quantity=4, piece_count=5
)
with self.export_data(
reverse('api-bom-list'),
{'part': assembly.pk, 'export_levels': 0},
export_plugin='bom-exporter',
) as data_file:
data = self.process_csv(
data_file,
required_cols=['BOM Level', 'Total Quantity'],
required_rows=2,
)
totals = {str(row['BOM Level']): Decimal(row['Total Quantity']) for row in data}
# Level 1: 2 x 3 = 6 sub-assemblies
self.assertEqual(totals['1'], 6)
# Level 2: (4 x 5) x 6 = 120 units of wire
self.assertEqual(totals['2'], 120)
def test_piece_count_get(self):
"""Test that piece_count is returned in GET response for BomItem."""
bom_item = BomItem.objects.first()
@@ -9,6 +9,7 @@ from django.db import transaction
from django.test import TestCase
import build.models
import part.filters
import stock.models
from common.settings import set_global_setting
@@ -783,6 +784,67 @@ class BomItemTest(TestCase):
# 200 * 10 * 10 = 20000
self.assertEqual(item.get_required_quantity(10), 20000)
def test_piece_count_unit_quantity(self):
"""Test that unit_quantity accounts for piece_count."""
item = BomItem.objects.get(part=100, sub_part=50)
item.set_quantity(2)
item.piece_count = 1
self.assertEqual(item.unit_quantity, 2)
item.piece_count = 5
self.assertEqual(item.unit_quantity, 10)
# Attrition and setup quantity are not included
item.attrition = 10
item.setup_quantity = 3
self.assertEqual(item.unit_quantity, 10)
def test_piece_count_can_build_quantity(self):
"""Test that BomItem.can_build_quantity accounts for piece_count."""
item = BomItem.objects.get(part=100, sub_part=50)
item.set_quantity(2)
item.piece_count = 5
item.attrition = 0
item.setup_quantity = 0
# 20 / (2 * 5) = 2
self.assertEqual(item.can_build_quantity(20), 2)
# With setup quantity and attrition: (25 - 5) / (2 * 5 * 1.25) = 1.6
item.setup_quantity = 5
item.attrition = 25
self.assertEqual(item.can_build_quantity(25), 1)
def test_piece_count_can_build(self):
"""Test that the 'can_build' annotation accounts for piece_count."""
assembly = Part.objects.create(
name='Cable assembly', description='Made with wire', assembly=True
)
wire = Part.objects.create(name='Wire', description='Cut-to-length wire')
stock.models.StockItem.objects.create(part=wire, quantity=20)
bom_item = BomItem.objects.create(
part=assembly, sub_part=wire, quantity=2, piece_count=5
)
# 20 / (2 * 5) = 2
self.assertEqual(assembly.can_build, 2)
item = part.filters.annotate_bom_item_can_build(
BomItem.objects.filter(pk=bom_item.pk)
).first()
self.assertAlmostEqual(item.can_build, 2)
# Without piece_count, 10 assemblies could be built
bom_item.piece_count = 1
bom_item.save()
self.assertEqual(assembly.can_build, 10)
def test_piece_count_with_attrition(self):
"""Test piece_count combined with attrition percentage."""
item = BomItem.objects.get(part=100, sub_part=50)
@@ -241,7 +241,7 @@ class BomExporterPlugin(DataExportMixin, InvenTreePlugin):
if self.export_total_quantity:
# Calculate the total quantity for this BOM item
total_quantity = Decimal(bom_item.quantity) * multiplier
total_quantity = bom_item.unit_quantity * multiplier
row['total_quantity'] = normalize(total_quantity)
self.bom_data.append(row)
@@ -258,7 +258,7 @@ class BomExporterPlugin(DataExportMixin, InvenTreePlugin):
self.process_bom_row(
item,
level=level + 1,
multiplier=multiplier * bom_item.quantity,
multiplier=multiplier * bom_item.unit_quantity,
**kwargs,
)
+2 -2
View File
@@ -366,13 +366,13 @@ class PartPricing(common.models.MetaMixin):
# Update cumulative totals
if bom_item_min is not None:
bom_item_min *= bom_item.quantity
bom_item_min *= bom_item.unit_quantity
cumulative_min += self.convert(bom_item_min)
any_min_elements = True
if bom_item_max is not None:
bom_item_max *= bom_item.quantity
bom_item_max *= bom_item.unit_quantity
cumulative_max += self.convert(bom_item_max)
any_max_elements = True
@@ -332,6 +332,33 @@ class PartPricingTests(InvenTreeTestCase):
self.assertEqual(pricing.overall_min, Money(expected_min, 'USD'))
self.assertEqual(pricing.overall_max, Money(expected_max, 'USD'))
@override_settings(TESTING_PRICING=True)
def test_bom_pricing_piece_count(self):
"""BOM pricing must account for the piece_count of each BOM item."""
sub_part = part.models.Part.objects.create(
name='Wire',
description='Cut-to-length wire',
component=True,
assembly=False,
)
sub_part_pricing = sub_part.pricing
sub_part_pricing.internal_cost_min = Money(2, 'USD')
sub_part_pricing.internal_cost_max = Money(3, 'USD')
sub_part_pricing.save()
part.models.BomItem.objects.create(
part=self.part, sub_part=sub_part, quantity=4, piece_count=5
)
self.part.refresh_from_db()
pricing = self.part.pricing
pricing.update_bom_cost()
# 4 x 5 = 20 units required per assembly
self.assertEqual(pricing.bom_cost_min, Money(40, 'USD'))
self.assertEqual(pricing.bom_cost_max, Money(60, 'USD'))
@override_settings(TESTING_PRICING=True)
def test_purchase_pricing(self):
"""Unit tests for historical purchase pricing."""
+10 -1
View File
@@ -85,6 +85,11 @@ function quantityChain(record: any): number[] {
return record.quantity_chain ?? [Number(record.quantity)];
}
// Quantity of a BomItem required for a single parent assembly (quantity x piece_count)
function unitQuantity(record: any): number {
return Number(record.quantity) * Number(record.piece_count ?? 1);
}
// Quantity of a BomItem required for a single unit of the top-level assembly
function totalQuantity(record: any): number {
return quantityChain(record).reduce((total, qty) => total * qty, 1);
@@ -821,7 +826,11 @@ export function BomTable({
// Multiply quantities through each level of the BOM
transformChild: (child: any, parent: any) => ({
...child,
quantity_chain: [Number(child.quantity), ...quantityChain(parent)]
quantity_chain: [
Number(child.quantity),
unitQuantity(parent),
...quantityChain(parent).slice(1)
]
})
};
}, [isEditing, showSubassemblies]);
@@ -69,6 +69,14 @@ function isLineConsumable(record: any): boolean {
);
}
/**
* Return the quantity of a BOM item required for a single assembly,
* accounting for the piece count (total material = quantity x piece_count).
*/
export function bomUnitQuantity(bom_item: any): number {
return Number(bom_item?.quantity ?? 0) * Number(bom_item?.piece_count ?? 1);
}
function allocationPartDiffers(allocation: any, bomPartId: number): boolean {
const allocatedPartId = allocation.part_detail?.pk;
@@ -465,7 +473,9 @@ export default function BuildLineTable({
render: (record: any) => {
return (
<Group justify='space-between' wrap='nowrap'>
<Text>{record.bom_item_detail?.quantity}</Text>
<Text>
{formatDecimal(bomUnitQuantity(record.bom_item_detail))}
</Text>
{record?.part_detail?.units && (
<Text size='xs'>[{record.part_detail.units}]</Text>
)}
@@ -581,7 +591,7 @@ export default function BuildLineTable({
if (output?.pk) {
// If an output is specified, we show the allocated quantity for that output
required = record.bom_item_detail?.quantity;
required = bomUnitQuantity(record.bom_item_detail);
}
if (allocated <= 0 && required <= 0) {
@@ -73,7 +73,7 @@ import {
import useStatusCodes from '../../hooks/UseStatusCodes';
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
import { useUserState } from '../../states/UserState';
import BuildLineTable from './BuildLineTable';
import BuildLineTable, { bomUnitQuantity } from './BuildLineTable';
type TestResultOverview = {
name: string;
@@ -309,10 +309,9 @@ export default function BuildOutputTable({
allocated += allocation.quantity;
});
if (
item.bom_item_detail?.quantity &&
allocated >= item.bom_item_detail.quantity
) {
const required = bomUnitQuantity(item.bom_item_detail);
if (required && allocated >= required) {
fullyAllocatedCount += 1;
}
});