Skip to content

Commit 84cd81d

Browse files
Build consume fix (#11529)
* Add new build task * Refactor background task for consuming build stock - Run as a single task - Improve query efficiency * Refactor consuming stock against build via API - Return task_id for monitoring - Keep frontend updated * Task tracking for auto-allocation * Add e2e integration tests: - Auto-allocate stock - Consume stock * Bump API version * Playwright test fixes * Adjust unit tests * Robustify unit test * Widen test scope * Adjust playwright test * Loosen test requirements again * idk, another change :| * Robustify test
1 parent 97aec82 commit 84cd81d

File tree

13 files changed

+287
-189
lines changed

13 files changed

+287
-189
lines changed

src/backend/InvenTree/InvenTree/api_version.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
"""InvenTree API version information."""
22

33
# InvenTree API version
4-
INVENTREE_API_VERSION = 464
4+
INVENTREE_API_VERSION = 465
55
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
66

77
INVENTREE_API_TEXT = """
88
9+
v465 -> 2026-03-18 : https://github.com/inventree/InvenTree/pull/11529/
10+
- BuildOrderAutoAllocate endpoint now returns a task ID which can be used to track the progress of the auto-allocation process
11+
- BuildOrderConsume endpoint now returns a task ID which can be used to track the progress of the stock consumption process
12+
913
v464 -> 2026-03-15 : https://github.com/inventree/InvenTree/pull/11527
1014
- Add API endpoint for monitoring the progress of a particular background task
1115

src/backend/InvenTree/build/api.py

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from django_filters.rest_framework.filterset import FilterSet
1212
from drf_spectacular.utils import extend_schema, extend_schema_field
1313
from rest_framework import serializers, status
14-
from rest_framework.exceptions import ValidationError
14+
from rest_framework.exceptions import NotFound, ValidationError
1515
from rest_framework.response import Response
1616

1717
import build.models as build_models
@@ -662,6 +662,13 @@ def get_source_build(self) -> Build | None:
662662
class BuildOrderContextMixin:
663663
"""Mixin class which adds build order as serializer context variable."""
664664

665+
def get_build(self):
666+
"""Return the Build object associated with this API endpoint."""
667+
try:
668+
return Build.objects.get(pk=self.kwargs.get('pk', None))
669+
except (ValueError, Build.DoesNotExist):
670+
raise NotFound(_('Build not found'))
671+
665672
def get_serializer_context(self):
666673
"""Add extra context information to the endpoint serializer."""
667674
ctx = super().get_serializer_context()
@@ -670,8 +677,8 @@ def get_serializer_context(self):
670677
ctx['to_complete'] = True
671678

672679
try:
673-
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
674-
except Exception:
680+
ctx['build'] = self.get_build()
681+
except NotFound:
675682
pass
676683

677684
return ctx
@@ -764,6 +771,37 @@ class BuildAutoAllocate(BuildOrderContextMixin, CreateAPI):
764771
queryset = Build.objects.none()
765772
serializer_class = build.serializers.BuildAutoAllocationSerializer
766773

774+
@extend_schema(responses={200: common.serializers.TaskDetailSerializer})
775+
def post(self, *args, **kwargs):
776+
"""Override the POST method to handle auto allocation task.
777+
778+
As this is offloaded to the background task,
779+
we return information about the background task which is performing the auto allocation operation.
780+
"""
781+
from build.tasks import auto_allocate_build
782+
from InvenTree.tasks import offload_task
783+
784+
build = self.get_build()
785+
serializer = self.get_serializer(data=self.request.data)
786+
serializer.is_valid(raise_exception=True)
787+
data = serializer.validated_data
788+
789+
# Offload the task to the background worker
790+
task_id = offload_task(
791+
auto_allocate_build,
792+
build.pk,
793+
location=data.get('location', None),
794+
exclude_location=data.get('exclude_location', None),
795+
interchangeable=data['interchangeable'],
796+
substitutes=data['substitutes'],
797+
optional_items=data['optional_items'],
798+
item_type=data.get('item_type', 'untracked'),
799+
group='build',
800+
)
801+
802+
response = common.serializers.TaskDetailSerializer.from_task(task_id).data
803+
return Response(response, status=response['http_status'])
804+
767805

768806
class BuildAllocate(BuildOrderContextMixin, CreateAPI):
769807
"""API endpoint to allocate stock items to a build order.
@@ -786,6 +824,39 @@ class BuildConsume(BuildOrderContextMixin, CreateAPI):
786824
queryset = Build.objects.none()
787825
serializer_class = build.serializers.BuildConsumeSerializer
788826

827+
@extend_schema(responses={200: common.serializers.TaskDetailSerializer})
828+
def post(self, *args, **kwargs):
829+
"""Override the POST method to handle consume task.
830+
831+
As this is offloaded to the background task,
832+
we return information about the background task which is performing the consume operation.
833+
"""
834+
from build.tasks import consume_build_stock
835+
from InvenTree.tasks import offload_task
836+
837+
build = self.get_build()
838+
serializer = self.get_serializer(data=self.request.data)
839+
serializer.is_valid(raise_exception=True)
840+
data = serializer.validated_data
841+
842+
# Extract the information we need to consume build stock
843+
items = data.get('items', [])
844+
lines = data.get('lines', [])
845+
notes = data.get('notes', '')
846+
847+
# Offload the task to the background worker
848+
task_id = offload_task(
849+
consume_build_stock,
850+
build.pk,
851+
lines=[line['build_line'].pk for line in lines],
852+
items={item['build_item'].pk: item['quantity'] for item in items},
853+
user_id=self.request.user.pk,
854+
notes=notes,
855+
)
856+
857+
response = common.serializers.TaskDetailSerializer.from_task(task_id).data
858+
return Response(response, status=response['http_status'])
859+
789860

790861
class BuildIssue(BuildOrderContextMixin, CreateAPI):
791862
"""API endpoint for issuing a BuildOrder."""

src/backend/InvenTree/build/serializers.py

Lines changed: 0 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
from rest_framework import serializers
2222
from rest_framework.serializers import ValidationError
2323

24-
import build.tasks
2524
import common.filters
2625
import common.settings
2726
import company.serializers
@@ -38,7 +37,6 @@
3837
NotesFieldMixin,
3938
enable_filter,
4039
)
41-
from InvenTree.tasks import offload_task
4240
from stock.generators import generate_batch_code
4341
from stock.models import StockItem, StockLocation
4442
from stock.serializers import (
@@ -51,7 +49,6 @@
5149

5250
from .models import Build, BuildItem, BuildLine
5351
from .status_codes import BuildStatus
54-
from .tasks import consume_build_item, consume_build_line
5552

5653

5754
class BuildSerializer(
@@ -1129,27 +1126,6 @@ class Meta:
11291126
help_text=_('Select item type to auto-allocate'),
11301127
)
11311128

1132-
def save(self):
1133-
"""Perform the auto-allocation step."""
1134-
import InvenTree.tasks
1135-
1136-
data = self.validated_data
1137-
1138-
build_order = self.context['build']
1139-
1140-
if not InvenTree.tasks.offload_task(
1141-
build.tasks.auto_allocate_build,
1142-
build_order.pk,
1143-
location=data.get('location', None),
1144-
exclude_location=data.get('exclude_location', None),
1145-
interchangeable=data['interchangeable'],
1146-
substitutes=data['substitutes'],
1147-
optional_items=data['optional_items'],
1148-
item_type=data.get('item_type', 'untracked'),
1149-
group='build',
1150-
):
1151-
raise ValidationError(_('Failed to start auto-allocation task'))
1152-
11531129

11541130
class BuildItemSerializer(
11551131
FilterableSerializerMixin, DataImportExportSerializerMixin, InvenTreeModelSerializer
@@ -1847,46 +1823,3 @@ def validate(self, data):
18471823
raise ValidationError(_('At least one item or line must be provided'))
18481824

18491825
return data
1850-
1851-
@transaction.atomic
1852-
def save(self):
1853-
"""Perform the stock consumption step."""
1854-
data = self.validated_data
1855-
request = self.context.get('request')
1856-
notes = data.get('notes', '')
1857-
1858-
# We may be passed either a list of BuildItem or BuildLine instances
1859-
items = data.get('items', [])
1860-
lines = data.get('lines', [])
1861-
1862-
with transaction.atomic():
1863-
# Process the provided BuildItem objects
1864-
for item in items:
1865-
build_item = item['build_item']
1866-
quantity = item['quantity']
1867-
1868-
if build_item.install_into:
1869-
# If the build item is tracked into an output, we do not consume now
1870-
# Instead, it gets consumed when the output is completed
1871-
continue
1872-
1873-
# Offload a background task to consume this BuildItem
1874-
offload_task(
1875-
consume_build_item,
1876-
build_item.pk,
1877-
quantity,
1878-
notes=notes,
1879-
user_id=request.user.pk if request else None,
1880-
)
1881-
1882-
# Process the provided BuildLine objects
1883-
for line in lines:
1884-
build_line = line['build_line']
1885-
1886-
# Offload a background task to consume this BuildLine
1887-
offload_task(
1888-
consume_build_line,
1889-
build_line.pk,
1890-
notes=notes,
1891-
user_id=request.user.pk if request else None,
1892-
)

src/backend/InvenTree/build/tasks.py

Lines changed: 44 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
from datetime import timedelta
44
from decimal import Decimal
5+
from typing import Optional
56

67
from django.contrib.auth.models import User
8+
from django.db import transaction
79
from django.utils.translation import gettext_lazy as _
810

911
import structlog
@@ -27,61 +29,53 @@ def auto_allocate_build(build_id: int, **kwargs):
2729
"""Run auto-allocation for a specified BuildOrder."""
2830
from build.models import Build
2931

30-
build_order = Build.objects.filter(pk=build_id).first()
31-
32-
if not build_order:
33-
logger.warning(
34-
'Could not auto-allocate BuildOrder <%s> - BuildOrder does not exist',
35-
build_id,
36-
)
37-
return
38-
32+
build_order = Build.objects.get(pk=build_id)
3933
build_order.auto_allocate_stock(**kwargs)
4034

4135

42-
@tracer.start_as_current_span('consume_build_item')
43-
def consume_build_item(
44-
item_id: str, quantity, notes: str = '', user_id: int | None = None
36+
@tracer.start_as_current_span('consume_build_stock')
37+
def consume_build_stock(
38+
build_id: int,
39+
lines: Optional[list[int]] = None,
40+
items: Optional[dict] = None,
41+
user_id: int | None = None,
42+
**kwargs,
4543
):
46-
"""Consume stock against a particular BuildOrderLineItem allocation."""
47-
from build.models import BuildItem
48-
49-
item = BuildItem.objects.filter(pk=item_id).first()
50-
51-
if not item:
52-
logger.warning(
53-
'Could not consume stock for BuildItem <%s> - BuildItem does not exist',
54-
item_id,
55-
)
56-
return
57-
58-
item.complete_allocation(
59-
quantity=quantity,
60-
notes=notes,
61-
user=User.objects.filter(pk=user_id).first() if user_id else None,
62-
)
63-
64-
65-
@tracer.start_as_current_span('consume_build_line')
66-
def consume_build_line(line_id: int, notes: str = '', user_id: int | None = None):
67-
"""Consume stock against a particular BuildOrderLineItem."""
68-
from build.models import BuildLine
69-
70-
line_item = BuildLine.objects.filter(pk=line_id).first()
44+
"""Consume stock for the specified BuildOrder.
7145
72-
if not line_item:
73-
logger.warning(
74-
'Could not consume stock for LineItem <%s> - LineItem does not exist',
75-
line_id,
76-
)
77-
return
78-
79-
for item in line_item.allocations.all():
80-
item.complete_allocation(
81-
quantity=item.quantity,
82-
notes=notes,
83-
user=User.objects.filter(pk=user_id).first() if user_id else None,
84-
)
46+
Arguments:
47+
build_id: The ID of the BuildOrder to consume stock for
48+
lines: Optional list of BuildLine IDs to consume
49+
items: Optional dict of BuildItem IDs (and quantities)to consume
50+
user_id: The ID of the user who initiated the stock consumption
51+
"""
52+
from build.models import Build, BuildItem, BuildLine
53+
54+
build = Build.objects.get(pk=build_id)
55+
user = User.objects.filter(pk=user_id).first() if user_id else None
56+
57+
lines = lines or []
58+
items = items or {}
59+
notes = kwargs.pop('notes', '')
60+
61+
# Extract the relevant BuildLine and BuildItem objects
62+
with transaction.atomic():
63+
# Consume each of the specified BuildLine objects
64+
for line_id in lines:
65+
if build_line := BuildLine.objects.filter(pk=line_id, build=build).first():
66+
for item in build_line.allocations.all():
67+
item.complete_allocation(
68+
quantity=item.quantity, notes=notes, user=user
69+
)
70+
71+
# Consume each of the specified BuildItem objects
72+
for item_id, quantity in items.items():
73+
if build_item := BuildItem.objects.filter(
74+
pk=item_id, build_line__build=build
75+
).first():
76+
build_item.complete_allocation(
77+
quantity=quantity, notes=notes, user=user
78+
)
8579

8680

8781
@tracer.start_as_current_span('complete_build_allocations')

src/backend/InvenTree/build/test_api.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -970,12 +970,12 @@ def test_auto_allocate_tracked(self):
970970
url = reverse('api-build-auto-allocate', kwargs={'pk': build.pk})
971971

972972
# Allocate only 'untracked' items - this should not allocate our tracked item
973-
self.post(url, data={'item_type': 'untracked'})
973+
self.post(url, data={'item_type': 'untracked'}, expected_code=200)
974974

975975
self.assertEqual(N, BuildItem.objects.count())
976976

977977
# Allocate 'tracked' items - this should allocate our tracked item
978-
self.post(url, data={'item_type': 'tracked'})
978+
self.post(url, data={'item_type': 'tracked'}, expected_code=200)
979979

980980
# A new BuildItem should have been created
981981
self.assertEqual(N + 1, BuildItem.objects.count())
@@ -1735,7 +1735,7 @@ def test_consume_lines(self):
17351735
'lines': [{'build_line': line.pk} for line in self.build.build_lines.all()]
17361736
}
17371737

1738-
self.post(url, data, expected_code=201)
1738+
self.post(url, data, expected_code=200)
17391739

17401740
self.assertEqual(self.build.allocated_stock.count(), 0)
17411741
self.assertEqual(self.build.consumed_stock.count(), 3)
@@ -1758,7 +1758,7 @@ def test_consume_items(self):
17581758
]
17591759
}
17601760

1761-
self.post(url, data, expected_code=201)
1761+
self.post(url, data, expected_code=200)
17621762

17631763
self.assertEqual(self.build.allocated_stock.count(), 0)
17641764
self.assertEqual(self.build.consumed_stock.count(), 3)

src/frontend/src/forms/BuildForms.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -853,7 +853,7 @@ export function useConsumeBuildItemsForm({
853853
url: ApiEndpoints.build_order_consume,
854854
pk: buildId,
855855
title: t`Consume Stock`,
856-
successMessage: t`Stock items scheduled to be consumed`,
856+
successMessage: null,
857857
onFormSuccess: onFormSuccess,
858858
size: '80%',
859859
fields: consumeFields,
@@ -954,7 +954,7 @@ export function useConsumeBuildLinesForm({
954954
url: ApiEndpoints.build_order_consume,
955955
pk: buildId,
956956
title: t`Consume Stock`,
957-
successMessage: t`Stock items scheduled to be consumed`,
957+
successMessage: null,
958958
onFormSuccess: onFormSuccess,
959959
fields: consumeFields,
960960
initialData: {

0 commit comments

Comments
 (0)