2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-10-30 20:55:42 +00:00

Build order consume (#8191)

* Adds "consumed" field to BuildLine model

* Expose new field to serializer

* Add "consumed" column to BuildLineTable

* Boolean column tweaks

* Increase consumed count when completing allocation

* Add comment

* Update migration

* Add serializer for consuming build items

* Improve build-line sub-table

* Refactor BuildItem.complete_allocation method

- Allow optional quantity to be specified
- Adjust the allocated quantity when consuming

* Perform consumption

* Add "BuildConsume" API endpoint

* Implement frontend form

* Fixes for serializer

* Enhance front-end form

* Fix rendering of BuildLineTable

* Further improve rendering

* Bump API version

* Update API description

* Add option to consume by specifying a list of BuildLine objects

* Add form to consume stock via BuildLine reference

* Fix api_version

* Fix backup colors

* Fix typo

* Fix migrations

* Fix build forms

* Forms fixes

* Fix formatting

* Fixes for BuildLineTable

* Account for consumed stock in requirements calculation

* Reduce API requirements for BuildLineTable

* Docs updates

* Updated playwright testing

* Update src/frontend/src/forms/BuildForms.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/frontend/src/tables/build/BuildLineTable.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Add unit test for filters

* Add functional tests

* Tweak query count

* Increase max query time for testing

* adjust unit test again

* Prevent consumption of "tracked" items

* Adjust playwright tests

* Fix table

* Fix rendering

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Oliver
2025-08-19 17:03:19 +10:00
committed by GitHub
parent ce6ffdac18
commit 49cc5fb137
24 changed files with 1079 additions and 142 deletions

View File

@@ -1,11 +1,15 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 385
INVENTREE_API_VERSION = 386
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v386 -> 2025-08-11 : https://github.com/inventree/InvenTree/pull/8191
- Adds "consumed" field to the BuildItem API
- Adds API endpoint to consume stock against a BuildOrder
v385 -> 2025-08-15 : https://github.com/inventree/InvenTree/pull/10174
- Adjust return type of PurchaseOrderReceive API serializer
- Now returns list of of the created stock items when receiving

View File

@@ -479,6 +479,14 @@ class BuildLineFilter(rest_filters.FilterSet):
return queryset.filter(allocated__gte=F('quantity'))
return queryset.filter(allocated__lt=F('quantity'))
consumed = rest_filters.BooleanFilter(label=_('Consumed'), method='filter_consumed')
def filter_consumed(self, queryset, name, value):
"""Filter by whether each BuildLine is fully consumed."""
if str2bool(value):
return queryset.filter(consumed__gte=F('quantity'))
return queryset.filter(consumed__lt=F('quantity'))
available = rest_filters.BooleanFilter(
label=_('Available'), method='filter_available'
)
@@ -494,6 +502,7 @@ class BuildLineFilter(rest_filters.FilterSet):
"""
flt = Q(
quantity__lte=F('allocated')
+ F('consumed')
+ F('available_stock')
+ F('available_substitute_stock')
+ F('available_variant_stock')
@@ -504,7 +513,7 @@ class BuildLineFilter(rest_filters.FilterSet):
return queryset.exclude(flt)
class BuildLineEndpoint:
class BuildLineMixin:
"""Mixin class for BuildLine API endpoints."""
queryset = BuildLine.objects.all()
@@ -553,7 +562,7 @@ class BuildLineEndpoint:
)
class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI):
class BuildLineList(BuildLineMixin, DataExportViewMixin, ListCreateAPI):
"""API endpoint for accessing a list of BuildLine objects."""
filterset_class = BuildLineFilter
@@ -562,6 +571,7 @@ class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI):
ordering_fields = [
'part',
'allocated',
'consumed',
'reference',
'quantity',
'consumable',
@@ -605,7 +615,7 @@ class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI):
return source_build
class BuildLineDetail(BuildLineEndpoint, RetrieveUpdateDestroyAPI):
class BuildLineDetail(BuildLineMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a BuildLine object."""
def get_source_build(self) -> Build | None:
@@ -734,6 +744,13 @@ class BuildAllocate(BuildOrderContextMixin, CreateAPI):
serializer_class = build.serializers.BuildAllocationSerializer
class BuildConsume(BuildOrderContextMixin, CreateAPI):
"""API endpoint to consume stock against a build order."""
queryset = Build.objects.none()
serializer_class = build.serializers.BuildConsumeSerializer
class BuildIssue(BuildOrderContextMixin, CreateAPI):
"""API endpoint for issuing a BuildOrder."""
@@ -953,6 +970,7 @@ build_api_urls = [
'<int:pk>/',
include([
path('allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
path('consume/', BuildConsume.as_view(), name='api-build-consume'),
path(
'auto-allocate/',
BuildAutoAllocate.as_view(),

View File

@@ -0,0 +1,19 @@
# Generated by Django 4.2.15 on 2024-09-26 10:11
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('build', '0057_build_external'),
]
operations = [
migrations.AddField(
model_name='buildline',
name='consumed',
field=models.DecimalField(decimal_places=5, default=0, help_text='Quantity of consumed stock', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Consumed'),
),
]

View File

@@ -1106,7 +1106,7 @@ class Build(
# Remove stock
for item in items:
item.complete_allocation(user)
item.complete_allocation(user=user)
# Delete allocation
items.all().delete()
@@ -1151,7 +1151,7 @@ class Build(
# Complete or discard allocations
for build_item in allocated_items:
if not discard_allocations:
build_item.complete_allocation(user)
build_item.complete_allocation(user=user)
# Delete allocations
allocated_items.delete()
@@ -1200,7 +1200,7 @@ class Build(
for build_item in allocated_items:
# Complete the allocation of stock for that item
build_item.complete_allocation(user)
build_item.complete_allocation(user=user)
# Delete the BuildItem objects from the database
allocated_items.all().delete()
@@ -1569,6 +1569,7 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo
build: Link to a Build object
bom_item: Link to a BomItem object
quantity: Number of units required for the Build
consumed: Number of units which have been consumed against this line item
"""
class Meta:
@@ -1614,6 +1615,15 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo
help_text=_('Required quantity for build order'),
)
consumed = models.DecimalField(
decimal_places=5,
max_digits=15,
default=0,
validators=[MinValueValidator(0)],
verbose_name=_('Consumed'),
help_text=_('Quantity of consumed stock'),
)
@property
def part(self):
"""Return the sub_part reference from the link bom_item."""
@@ -1645,6 +1655,10 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo
"""Return True if this BuildLine is over-allocated."""
return self.allocated_quantity() > self.quantity
def is_fully_consumed(self):
"""Return True if this BuildLine is fully consumed."""
return self.consumed >= self.quantity
class BuildItem(InvenTree.models.InvenTreeMetadataModel):
"""A BuildItem links multiple StockItem objects to a Build.
@@ -1812,20 +1826,36 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel):
return self.build_line.bom_item if self.build_line else None
@transaction.atomic
def complete_allocation(self, user, notes=''):
def complete_allocation(self, quantity=None, notes='', user=None):
"""Complete the allocation of this BuildItem into the output stock item.
Arguments:
quantity: The quantity to allocate (default is the full quantity)
notes: Additional notes to add to the transaction
user: The user completing the allocation
- If the referenced part is trackable, the stock item will be *installed* into the build output
- If the referenced part is *not* trackable, the stock item will be *consumed* by the build order
TODO: This is quite expensive (in terms of number of database hits) - and requires some thought
TODO: Revisit, and refactor!
"""
# If the quantity is not provided, use the quantity of this BuildItem
if quantity is None:
quantity = self.quantity
item = self.stock_item
# Ensure we are not allocating more than available
if quantity > item.quantity:
raise ValidationError({
'quantity': _('Allocated quantity exceeds available stock quantity')
})
# Split the allocated stock if there are more available than allocated
if item.quantity > self.quantity:
item = item.splitStock(self.quantity, None, user, notes=notes)
if item.quantity > quantity:
item = item.splitStock(quantity, None, user, notes=notes)
# For a trackable part, special consideration needed!
if item.part.trackable:
@@ -1835,7 +1865,7 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel):
# Install the stock item into the output
self.install_into.installStockItem(
item, self.quantity, user, notes, build=self.build
item, quantity, user, notes, build=self.build
)
else:
@@ -1851,6 +1881,18 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel):
deltas={'buildorder': self.build.pk, 'quantity': float(item.quantity)},
)
# Increase the "consumed" count for the associated BuildLine
self.build_line.consumed += quantity
self.build_line.save()
# Decrease the allocated quantity
self.quantity = max(0, self.quantity - quantity)
if self.quantity <= 0:
self.delete()
else:
self.save()
build_line = models.ForeignKey(
BuildLine, on_delete=models.CASCADE, null=True, related_name='allocations'
)

View File

@@ -171,6 +171,9 @@ class BuildSerializer(
def __init__(self, *args, **kwargs):
"""Determine if extra serializer fields are required."""
part_detail = kwargs.pop('part_detail', True)
user_detail = kwargs.pop('user_detail', True)
project_code_detail = kwargs.pop('project_code_detail', True)
kwargs.pop('create', False)
super().__init__(*args, **kwargs)
@@ -181,6 +184,15 @@ class BuildSerializer(
if not part_detail:
self.fields.pop('part_detail', None)
if not user_detail:
self.fields.pop('issued_by_detail', None)
self.fields.pop('responsible_detail', None)
if not project_code_detail:
self.fields.pop('project_code', None)
self.fields.pop('project_code_label', None)
self.fields.pop('project_code_detail', None)
def validate_reference(self, reference):
"""Custom validation for the Build reference field."""
# Ensure the reference matches the required pattern
@@ -1000,7 +1012,7 @@ class BuildAllocationItemSerializer(serializers.Serializer):
class BuildAllocationSerializer(serializers.Serializer):
"""DRF serializer for allocation stock items against a build order."""
"""Serializer for allocating stock items against a build order."""
class Meta:
"""Serializer metaclass."""
@@ -1302,6 +1314,8 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
'build',
'bom_item',
'quantity',
'consumed',
'allocations',
'part',
# Build detail fields
'build_reference',
@@ -1353,6 +1367,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
if not part_detail:
self.fields.pop('part_detail', None)
self.fields.pop('part_category_name', None)
if not build_detail:
self.fields.pop('build_detail', None)
@@ -1379,7 +1394,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
read_only=True,
)
allocations = BuildItemSerializer(many=True, read_only=True)
allocations = BuildItemSerializer(many=True, read_only=True, build_detail=False)
# BOM item info fields
reference = serializers.CharField(
@@ -1405,6 +1420,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
)
quantity = serializers.FloatField(label=_('Quantity'))
consumed = serializers.FloatField(label=_('Consumed'))
bom_item = serializers.PrimaryKeyRelatedField(label=_('BOM Item'), read_only=True)
@@ -1437,17 +1453,23 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
read_only=True,
pricing=False,
)
build_detail = BuildSerializer(
label=_('Build'),
source='build',
part_detail=False,
many=False,
read_only=True,
allow_null=True,
part_detail=False,
user_detail=False,
project_code_detail=False,
)
# Annotated (calculated) fields
allocated = serializers.FloatField(label=_('Allocated Stock'), read_only=True)
# Total quantity of allocated stock
allocated = serializers.FloatField(label=_('Allocated'), read_only=True)
on_order = serializers.FloatField(label=_('On Order'), read_only=True)
in_production = serializers.FloatField(label=_('In Production'), read_only=True)
scheduled_to_build = serializers.FloatField(
@@ -1498,6 +1520,9 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
'allocations__stock_item',
'allocations__stock_item__part',
'allocations__stock_item__location',
'bom_item',
'bom_item__part',
'bom_item__sub_part',
'bom_item__sub_part__stock_items',
'bom_item__sub_part__stock_items__allocations',
'bom_item__sub_part__stock_items__sales_order_allocations',
@@ -1518,7 +1543,6 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
'build__destination',
'build__take_from',
'build__completed_by',
'build__issued_by',
'build__sales_order',
'build__parent',
'build__notes',
@@ -1692,3 +1716,177 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
)
return queryset
class BuildConsumeAllocationSerializer(serializers.Serializer):
"""Serializer for an individual BuildItem to be consumed against a BuildOrder."""
class Meta:
"""Serializer metaclass."""
fields = ['build_item', 'quantity']
build_item = serializers.PrimaryKeyRelatedField(
queryset=BuildItem.objects.all(), many=False, allow_null=False, required=True
)
quantity = serializers.DecimalField(
max_digits=15, decimal_places=5, min_value=Decimal(0), required=True
)
def validate_quantity(self, quantity):
"""Perform validation on the 'quantity' field."""
if quantity <= 0:
raise ValidationError(_('Quantity must be greater than zero'))
return quantity
def validate(self, data):
"""Validate the serializer data."""
data = super().validate(data)
build_item = data['build_item']
quantity = data['quantity']
if quantity > build_item.quantity:
raise ValidationError({
'quantity': _('Consumed quantity exceeds allocated quantity')
})
return data
class BuildConsumeLineItemSerializer(serializers.Serializer):
"""Serializer for an individual BuildLine to be consumed against a BuildOrder."""
class Meta:
"""Serializer metaclass."""
fields = ['build_line']
build_line = serializers.PrimaryKeyRelatedField(
queryset=BuildLine.objects.all(), many=False, allow_null=False, required=True
)
class BuildConsumeSerializer(serializers.Serializer):
"""Serializer for consuming allocations against a BuildOrder.
- Consumes allocated stock items, increasing the 'consumed' field for each BuildLine.
- Stock can be consumed by passing either a list of BuildItem objects, or a list of BuildLine objects.
"""
class Meta:
"""Serializer metaclass."""
fields = ['items', 'lines', 'notes']
items = BuildConsumeAllocationSerializer(many=True, required=False)
lines = BuildConsumeLineItemSerializer(many=True, required=False)
notes = serializers.CharField(
label=_('Notes'),
help_text=_('Optional notes for the stock consumption'),
required=False,
allow_blank=True,
)
def validate_items(self, items):
"""Validate the BuildItem list passed to the serializer."""
build_order = self.context['build']
seen = set()
for item in items:
build_item = item['build_item']
# BuildItem must point to the correct build order
if build_item.build != build_order:
raise ValidationError(
_('Build item must point to the correct build order')
)
# Prevent duplicate item allocation
if build_item.pk in seen:
raise ValidationError(_('Duplicate build item allocation'))
seen.add(build_item.pk)
return items
def validate_lines(self, lines):
"""Validate the BuildLine list passed to the serializer."""
build_order = self.context['build']
seen = set()
for line in lines:
build_line = line['build_line']
# BuildLine must point to the correct build order
if build_line.build != build_order:
raise ValidationError(
_('Build line must point to the correct build order')
)
# Prevent duplicate line allocation
if build_line.pk in seen:
raise ValidationError(_('Duplicate build line allocation'))
seen.add(build_line.pk)
return lines
def validate(self, data):
"""Validate the serializer data."""
items = data.get('items', [])
lines = data.get('lines', [])
if len(items) == 0 and len(lines) == 0:
raise ValidationError(_('At least one item or line must be provided'))
return data
def save(self):
"""Perform the stock consumption step."""
data = self.validated_data
request = self.context.get('request')
notes = data.get('notes', '')
items = data.get('items', [])
lines = data.get('lines', [])
with transaction.atomic():
# Process the provided BuildItem objects
for item in items:
build_item = item['build_item']
quantity = item['quantity']
if build_item.install_into:
# If the build item is tracked into an output, we do not consume now
# Instead, it gets consumed when the output is completed
continue
build_item.complete_allocation(
quantity=quantity,
notes=notes,
user=request.user if request else None,
)
# Process the provided BuildLine objects
for line in lines:
build_line = line['build_line']
# In this case, perform full consumption of all allocated stock
for item in build_line.allocations.all():
# If the build item is tracked into an output, we do not consume now
# Instead, it gets consumed when the output is completed
if item.install_into:
continue
item.complete_allocation(
quantity=item.quantity,
notes=notes,
user=request.user if request else None,
)

View File

@@ -990,11 +990,12 @@ class BuildOverallocationTest(BuildAPITest):
self.assertEqual(si.quantity, oq)
# Accept overallocated stock
# TODO: (2025-07-16) Look into optimizing this API query to reduce DB hits
self.post(
self.url,
{'accept_overallocated': 'accept'},
expected_code=201,
max_query_count=375,
max_query_count=400,
)
self.build.refresh_from_db()
@@ -1009,11 +1010,12 @@ class BuildOverallocationTest(BuildAPITest):
def test_overallocated_can_trim(self):
"""Test build order will trim/de-allocate overallocated stock when requested."""
# TODO: (2025-07-16) Look into optimizing this API query to reduce DB hits
self.post(
self.url,
{'accept_overallocated': 'trim'},
expected_code=201,
max_query_count=400,
max_query_count=450,
)
# Note: Large number of queries is due to pricing recalculation for each stock item
@@ -1323,3 +1325,177 @@ class BuildLineTests(BuildAPITest):
self.assertGreater(n_f, 0)
self.assertEqual(n_t + n_f, BuildLine.objects.count())
def test_filter_consumed(self):
"""Filter for the 'consumed' status."""
# Create a new build order
assembly = Part.objects.create(
name='Test Assembly',
description='Test Assembly Description',
assembly=True,
trackable=True,
)
for idx in range(3):
component = Part.objects.create(
name=f'Test Component {idx}',
description=f'Test Component Description {idx}',
trackable=True,
component=True,
)
# Create a BOM item for the assembly
BomItem.objects.create(part=assembly, sub_part=component, quantity=10)
build = Build.objects.create(
part=assembly, reference='BO-12348', quantity=10, title='Test Build'
)
lines = list(build.build_lines.all())
self.assertEqual(len(lines), 3)
for line in lines:
self.assertEqual(line.quantity, 100)
self.assertEqual(line.consumed, 0)
# Artificially "consume" some of the build lines
lines[0].consumed = 1
lines[0].save()
lines[1].consumed = 50
lines[1].save()
lines[2].consumed = 100
lines[2].save()
url = reverse('api-build-line-list')
response = self.get(url, {'build': build.pk, 'consumed': True})
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]['pk'], lines[2].pk)
self.assertEqual(response.data[0]['consumed'], 100)
self.assertEqual(response.data[0]['quantity'], 100)
response = self.get(url, {'build': build.pk, 'consumed': False})
self.assertEqual(len(response.data), 2)
self.assertEqual(response.data[0]['pk'], lines[0].pk)
self.assertEqual(response.data[0]['consumed'], 1)
self.assertEqual(response.data[0]['quantity'], 100)
self.assertEqual(response.data[1]['pk'], lines[1].pk)
self.assertEqual(response.data[1]['consumed'], 50)
self.assertEqual(response.data[1]['quantity'], 100)
# Check that the 'available' filter works correctly also when lines are partially consumed
for line in lines:
StockItem.objects.create(part=line.bom_item.sub_part, quantity=60)
# Note: The max_query_time is bumped up here, as postgresql backend has some strange issues (only during testing)
response = self.get(
url, {'build': build.pk, 'available': True}, max_query_time=15
)
# We expect 2 lines to have "available" stock
self.assertEqual(len(response.data), 2)
# Note: The max_query_time is bumped up here, as postgresql backend has some strange issues (only during testing)
response = self.get(
url, {'build': build.pk, 'available': False}, max_query_time=15
)
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]['pk'], lines[0].pk)
class BuildConsumeTest(BuildAPITest):
"""Test consuming allocated stock."""
def setUp(self):
"""Set up test data."""
super().setUp()
self.assembly = Part.objects.create(
name='Test Assembly', description='Test Assembly Description', assembly=True
)
self.components = [
Part.objects.create(
name=f'Test Component {i}',
description=f'Test Component Description {i}',
component=True,
)
for i in range(3)
]
self.stock_items = [
StockItem.objects.create(part=component, quantity=1000)
for component in self.components
]
self.bom_items = [
BomItem.objects.create(part=self.assembly, sub_part=component, quantity=10)
for component in self.components
]
self.build = Build.objects.create(
part=self.assembly, reference='BO-12349', quantity=10, title='Test Build'
)
def allocate_stock(self):
"""Allocate stock items to the build."""
data = {
'items': [
{'build_line': line.pk, 'stock_item': si.pk, 'quantity': 100}
for line, si in zip(self.build.build_lines.all(), self.stock_items)
]
}
self.post(
reverse('api-build-allocate', kwargs={'pk': self.build.pk}),
data,
expected_code=201,
)
def test_consume_lines(self):
"""Test consuming against build lines."""
self.allocate_stock()
self.assertEqual(self.build.allocated_stock.count(), 3)
self.assertEqual(self.build.consumed_stock.count(), 0)
url = reverse('api-build-consume', kwargs={'pk': self.build.pk})
data = {
'lines': [{'build_line': line.pk} for line in self.build.build_lines.all()]
}
self.post(url, data, expected_code=201)
self.assertEqual(self.build.allocated_stock.count(), 0)
self.assertEqual(self.build.consumed_stock.count(), 3)
for line in self.build.build_lines.all():
self.assertEqual(line.consumed, 100)
def test_consume_items(self):
"""Test consuming against build items."""
self.allocate_stock()
self.assertEqual(self.build.allocated_stock.count(), 3)
self.assertEqual(self.build.consumed_stock.count(), 0)
url = reverse('api-build-consume', kwargs={'pk': self.build.pk})
data = {
'items': [
{'build_item': item.pk, 'quantity': item.quantity}
for item in self.build.allocated_stock.all()
]
}
self.post(url, data, expected_code=201)
self.assertEqual(self.build.allocated_stock.count(), 0)
self.assertEqual(self.build.consumed_stock.count(), 3)
for line in self.build.build_lines.all():
self.assertEqual(line.consumed, 100)

View File

@@ -1400,9 +1400,10 @@ class Part(
# Match BOM item to build
for bom_item in bom_items:
build_quantity = build.quantity * bom_item.quantity
build_line = build.build_lines.filter(bom_item=bom_item).first()
quantity += build_quantity
line_quantity = max(0, build_line.quantity - build_line.consumed)
quantity += line_quantity
return quantity