mirror of
https://github.com/inventree/InvenTree.git
synced 2026-04-14 07:18:44 +00:00
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
This commit is contained in:
@@ -1,11 +1,15 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 464
|
||||
INVENTREE_API_VERSION = 465
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v465 -> 2026-03-18 : https://github.com/inventree/InvenTree/pull/11529/
|
||||
- BuildOrderAutoAllocate endpoint now returns a task ID which can be used to track the progress of the auto-allocation process
|
||||
- BuildOrderConsume endpoint now returns a task ID which can be used to track the progress of the stock consumption process
|
||||
|
||||
v464 -> 2026-03-15 : https://github.com/inventree/InvenTree/pull/11527
|
||||
- Add API endpoint for monitoring the progress of a particular background task
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import django_filters.rest_framework.filters as rest_filters
|
||||
from django_filters.rest_framework.filterset import FilterSet
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_field
|
||||
from rest_framework import serializers, status
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.exceptions import NotFound, ValidationError
|
||||
from rest_framework.response import Response
|
||||
|
||||
import build.models as build_models
|
||||
@@ -662,6 +662,13 @@ class BuildLineDetail(BuildLineMixin, OutputOptionsMixin, RetrieveUpdateDestroyA
|
||||
class BuildOrderContextMixin:
|
||||
"""Mixin class which adds build order as serializer context variable."""
|
||||
|
||||
def get_build(self):
|
||||
"""Return the Build object associated with this API endpoint."""
|
||||
try:
|
||||
return Build.objects.get(pk=self.kwargs.get('pk', None))
|
||||
except (ValueError, Build.DoesNotExist):
|
||||
raise NotFound(_('Build not found'))
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""Add extra context information to the endpoint serializer."""
|
||||
ctx = super().get_serializer_context()
|
||||
@@ -670,8 +677,8 @@ class BuildOrderContextMixin:
|
||||
ctx['to_complete'] = True
|
||||
|
||||
try:
|
||||
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
|
||||
except Exception:
|
||||
ctx['build'] = self.get_build()
|
||||
except NotFound:
|
||||
pass
|
||||
|
||||
return ctx
|
||||
@@ -764,6 +771,37 @@ class BuildAutoAllocate(BuildOrderContextMixin, CreateAPI):
|
||||
queryset = Build.objects.none()
|
||||
serializer_class = build.serializers.BuildAutoAllocationSerializer
|
||||
|
||||
@extend_schema(responses={200: common.serializers.TaskDetailSerializer})
|
||||
def post(self, *args, **kwargs):
|
||||
"""Override the POST method to handle auto allocation task.
|
||||
|
||||
As this is offloaded to the background task,
|
||||
we return information about the background task which is performing the auto allocation operation.
|
||||
"""
|
||||
from build.tasks import auto_allocate_build
|
||||
from InvenTree.tasks import offload_task
|
||||
|
||||
build = self.get_build()
|
||||
serializer = self.get_serializer(data=self.request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
|
||||
# Offload the task to the background worker
|
||||
task_id = offload_task(
|
||||
auto_allocate_build,
|
||||
build.pk,
|
||||
location=data.get('location', None),
|
||||
exclude_location=data.get('exclude_location', None),
|
||||
interchangeable=data['interchangeable'],
|
||||
substitutes=data['substitutes'],
|
||||
optional_items=data['optional_items'],
|
||||
item_type=data.get('item_type', 'untracked'),
|
||||
group='build',
|
||||
)
|
||||
|
||||
response = common.serializers.TaskDetailSerializer.from_task(task_id).data
|
||||
return Response(response, status=response['http_status'])
|
||||
|
||||
|
||||
class BuildAllocate(BuildOrderContextMixin, CreateAPI):
|
||||
"""API endpoint to allocate stock items to a build order.
|
||||
@@ -786,6 +824,39 @@ class BuildConsume(BuildOrderContextMixin, CreateAPI):
|
||||
queryset = Build.objects.none()
|
||||
serializer_class = build.serializers.BuildConsumeSerializer
|
||||
|
||||
@extend_schema(responses={200: common.serializers.TaskDetailSerializer})
|
||||
def post(self, *args, **kwargs):
|
||||
"""Override the POST method to handle consume task.
|
||||
|
||||
As this is offloaded to the background task,
|
||||
we return information about the background task which is performing the consume operation.
|
||||
"""
|
||||
from build.tasks import consume_build_stock
|
||||
from InvenTree.tasks import offload_task
|
||||
|
||||
build = self.get_build()
|
||||
serializer = self.get_serializer(data=self.request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
|
||||
# Extract the information we need to consume build stock
|
||||
items = data.get('items', [])
|
||||
lines = data.get('lines', [])
|
||||
notes = data.get('notes', '')
|
||||
|
||||
# Offload the task to the background worker
|
||||
task_id = offload_task(
|
||||
consume_build_stock,
|
||||
build.pk,
|
||||
lines=[line['build_line'].pk for line in lines],
|
||||
items={item['build_item'].pk: item['quantity'] for item in items},
|
||||
user_id=self.request.user.pk,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
response = common.serializers.TaskDetailSerializer.from_task(task_id).data
|
||||
return Response(response, status=response['http_status'])
|
||||
|
||||
|
||||
class BuildIssue(BuildOrderContextMixin, CreateAPI):
|
||||
"""API endpoint for issuing a BuildOrder."""
|
||||
|
||||
@@ -21,7 +21,6 @@ from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
from rest_framework.serializers import ValidationError
|
||||
|
||||
import build.tasks
|
||||
import common.filters
|
||||
import common.settings
|
||||
import company.serializers
|
||||
@@ -38,7 +37,6 @@ from InvenTree.serializers import (
|
||||
NotesFieldMixin,
|
||||
enable_filter,
|
||||
)
|
||||
from InvenTree.tasks import offload_task
|
||||
from stock.generators import generate_batch_code
|
||||
from stock.models import StockItem, StockLocation
|
||||
from stock.serializers import (
|
||||
@@ -51,7 +49,6 @@ from users.serializers import OwnerSerializer, UserSerializer
|
||||
|
||||
from .models import Build, BuildItem, BuildLine
|
||||
from .status_codes import BuildStatus
|
||||
from .tasks import consume_build_item, consume_build_line
|
||||
|
||||
|
||||
class BuildSerializer(
|
||||
@@ -1129,27 +1126,6 @@ class BuildAutoAllocationSerializer(serializers.Serializer):
|
||||
help_text=_('Select item type to auto-allocate'),
|
||||
)
|
||||
|
||||
def save(self):
|
||||
"""Perform the auto-allocation step."""
|
||||
import InvenTree.tasks
|
||||
|
||||
data = self.validated_data
|
||||
|
||||
build_order = self.context['build']
|
||||
|
||||
if not InvenTree.tasks.offload_task(
|
||||
build.tasks.auto_allocate_build,
|
||||
build_order.pk,
|
||||
location=data.get('location', None),
|
||||
exclude_location=data.get('exclude_location', None),
|
||||
interchangeable=data['interchangeable'],
|
||||
substitutes=data['substitutes'],
|
||||
optional_items=data['optional_items'],
|
||||
item_type=data.get('item_type', 'untracked'),
|
||||
group='build',
|
||||
):
|
||||
raise ValidationError(_('Failed to start auto-allocation task'))
|
||||
|
||||
|
||||
class BuildItemSerializer(
|
||||
FilterableSerializerMixin, DataImportExportSerializerMixin, InvenTreeModelSerializer
|
||||
@@ -1847,46 +1823,3 @@ class BuildConsumeSerializer(serializers.Serializer):
|
||||
raise ValidationError(_('At least one item or line must be provided'))
|
||||
|
||||
return data
|
||||
|
||||
@transaction.atomic
|
||||
def save(self):
|
||||
"""Perform the stock consumption step."""
|
||||
data = self.validated_data
|
||||
request = self.context.get('request')
|
||||
notes = data.get('notes', '')
|
||||
|
||||
# We may be passed either a list of BuildItem or BuildLine instances
|
||||
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
|
||||
|
||||
# Offload a background task to consume this BuildItem
|
||||
offload_task(
|
||||
consume_build_item,
|
||||
build_item.pk,
|
||||
quantity,
|
||||
notes=notes,
|
||||
user_id=request.user.pk if request else None,
|
||||
)
|
||||
|
||||
# Process the provided BuildLine objects
|
||||
for line in lines:
|
||||
build_line = line['build_line']
|
||||
|
||||
# Offload a background task to consume this BuildLine
|
||||
offload_task(
|
||||
consume_build_line,
|
||||
build_line.pk,
|
||||
notes=notes,
|
||||
user_id=request.user.pk if request else None,
|
||||
)
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import transaction
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import structlog
|
||||
@@ -27,61 +29,53 @@ def auto_allocate_build(build_id: int, **kwargs):
|
||||
"""Run auto-allocation for a specified BuildOrder."""
|
||||
from build.models import Build
|
||||
|
||||
build_order = Build.objects.filter(pk=build_id).first()
|
||||
|
||||
if not build_order:
|
||||
logger.warning(
|
||||
'Could not auto-allocate BuildOrder <%s> - BuildOrder does not exist',
|
||||
build_id,
|
||||
)
|
||||
return
|
||||
|
||||
build_order = Build.objects.get(pk=build_id)
|
||||
build_order.auto_allocate_stock(**kwargs)
|
||||
|
||||
|
||||
@tracer.start_as_current_span('consume_build_item')
|
||||
def consume_build_item(
|
||||
item_id: str, quantity, notes: str = '', user_id: int | None = None
|
||||
@tracer.start_as_current_span('consume_build_stock')
|
||||
def consume_build_stock(
|
||||
build_id: int,
|
||||
lines: Optional[list[int]] = None,
|
||||
items: Optional[dict] = None,
|
||||
user_id: int | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Consume stock against a particular BuildOrderLineItem allocation."""
|
||||
from build.models import BuildItem
|
||||
"""Consume stock for the specified BuildOrder.
|
||||
|
||||
item = BuildItem.objects.filter(pk=item_id).first()
|
||||
Arguments:
|
||||
build_id: The ID of the BuildOrder to consume stock for
|
||||
lines: Optional list of BuildLine IDs to consume
|
||||
items: Optional dict of BuildItem IDs (and quantities)to consume
|
||||
user_id: The ID of the user who initiated the stock consumption
|
||||
"""
|
||||
from build.models import Build, BuildItem, BuildLine
|
||||
|
||||
if not item:
|
||||
logger.warning(
|
||||
'Could not consume stock for BuildItem <%s> - BuildItem does not exist',
|
||||
item_id,
|
||||
)
|
||||
return
|
||||
build = Build.objects.get(pk=build_id)
|
||||
user = User.objects.filter(pk=user_id).first() if user_id else None
|
||||
|
||||
item.complete_allocation(
|
||||
quantity=quantity,
|
||||
notes=notes,
|
||||
user=User.objects.filter(pk=user_id).first() if user_id else None,
|
||||
)
|
||||
lines = lines or []
|
||||
items = items or {}
|
||||
notes = kwargs.pop('notes', '')
|
||||
|
||||
# Extract the relevant BuildLine and BuildItem objects
|
||||
with transaction.atomic():
|
||||
# Consume each of the specified BuildLine objects
|
||||
for line_id in lines:
|
||||
if build_line := BuildLine.objects.filter(pk=line_id, build=build).first():
|
||||
for item in build_line.allocations.all():
|
||||
item.complete_allocation(
|
||||
quantity=item.quantity, notes=notes, user=user
|
||||
)
|
||||
|
||||
@tracer.start_as_current_span('consume_build_line')
|
||||
def consume_build_line(line_id: int, notes: str = '', user_id: int | None = None):
|
||||
"""Consume stock against a particular BuildOrderLineItem."""
|
||||
from build.models import BuildLine
|
||||
|
||||
line_item = BuildLine.objects.filter(pk=line_id).first()
|
||||
|
||||
if not line_item:
|
||||
logger.warning(
|
||||
'Could not consume stock for LineItem <%s> - LineItem does not exist',
|
||||
line_id,
|
||||
)
|
||||
return
|
||||
|
||||
for item in line_item.allocations.all():
|
||||
item.complete_allocation(
|
||||
quantity=item.quantity,
|
||||
notes=notes,
|
||||
user=User.objects.filter(pk=user_id).first() if user_id else None,
|
||||
)
|
||||
# Consume each of the specified BuildItem objects
|
||||
for item_id, quantity in items.items():
|
||||
if build_item := BuildItem.objects.filter(
|
||||
pk=item_id, build_line__build=build
|
||||
).first():
|
||||
build_item.complete_allocation(
|
||||
quantity=quantity, notes=notes, user=user
|
||||
)
|
||||
|
||||
|
||||
@tracer.start_as_current_span('complete_build_allocations')
|
||||
|
||||
@@ -970,12 +970,12 @@ class BuildAllocationTest(BuildAPITest):
|
||||
url = reverse('api-build-auto-allocate', kwargs={'pk': build.pk})
|
||||
|
||||
# Allocate only 'untracked' items - this should not allocate our tracked item
|
||||
self.post(url, data={'item_type': 'untracked'})
|
||||
self.post(url, data={'item_type': 'untracked'}, expected_code=200)
|
||||
|
||||
self.assertEqual(N, BuildItem.objects.count())
|
||||
|
||||
# Allocate 'tracked' items - this should allocate our tracked item
|
||||
self.post(url, data={'item_type': 'tracked'})
|
||||
self.post(url, data={'item_type': 'tracked'}, expected_code=200)
|
||||
|
||||
# A new BuildItem should have been created
|
||||
self.assertEqual(N + 1, BuildItem.objects.count())
|
||||
@@ -1735,7 +1735,7 @@ class BuildConsumeTest(BuildAPITest):
|
||||
'lines': [{'build_line': line.pk} for line in self.build.build_lines.all()]
|
||||
}
|
||||
|
||||
self.post(url, data, expected_code=201)
|
||||
self.post(url, data, expected_code=200)
|
||||
|
||||
self.assertEqual(self.build.allocated_stock.count(), 0)
|
||||
self.assertEqual(self.build.consumed_stock.count(), 3)
|
||||
@@ -1758,7 +1758,7 @@ class BuildConsumeTest(BuildAPITest):
|
||||
]
|
||||
}
|
||||
|
||||
self.post(url, data, expected_code=201)
|
||||
self.post(url, data, expected_code=200)
|
||||
|
||||
self.assertEqual(self.build.allocated_stock.count(), 0)
|
||||
self.assertEqual(self.build.consumed_stock.count(), 3)
|
||||
|
||||
Reference in New Issue
Block a user