2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-04-05 19:10:54 +00:00

Fix complete_sales_order_shipment task (#11525)

* Fix complete_sales_order_shipment task

- Perform allocation *before* marking shipment as complete
- Ensure task is not marked as complete before it is actually done

* Add unit test

* Provide task status tracking for shipment completion

* Add integration testing

* Address unit test issues

* Bump API version

* Enhance playwright test
This commit is contained in:
Oliver
2026-03-18 08:05:16 +11:00
committed by GitHub
parent b10fd949d3
commit 488bd5f923
14 changed files with 229 additions and 122 deletions

View File

@@ -1,12 +1,15 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 465
INVENTREE_API_VERSION = 466
"""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/
v466 -> 2026-03-17 : https://github.com/inventree/InvenTree/pull/11525
- SalesOrderShipmentComplete endpoint now returns a task ID which can be used to track the progress of the shipment completion process
v465 -> 2026-03-16 : 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

View File

@@ -18,6 +18,7 @@ from django_ical.views import ICalFeed
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, extend_schema_field
from rest_framework import status
from rest_framework.exceptions import NotFound
from rest_framework.response import Response
import build.models
@@ -36,7 +37,7 @@ from InvenTree.api import (
)
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
from InvenTree.filters import SEARCH_ORDER_FILTER, InvenTreeDateFilter
from InvenTree.helpers import str2bool
from InvenTree.helpers import current_date, str2bool
from InvenTree.helpers_model import construct_absolute_url, get_base_url
from InvenTree.mixins import (
CreateAPI,
@@ -1426,20 +1427,46 @@ class SalesOrderShipmentComplete(CreateAPI):
queryset = models.SalesOrderShipment.objects.all()
serializer_class = serializers.SalesOrderShipmentCompleteSerializer
def get_shipment(self):
"""Return the shipment associated with this endpoint."""
try:
shipment = models.SalesOrderShipment.objects.get(
pk=self.kwargs.get('pk', None)
)
except (ValueError, models.SalesOrderShipment.DoesNotExist):
raise NotFound(detail=_('Shipment not found'))
return shipment
def get_serializer_context(self):
"""Pass the request object to the serializer."""
ctx = super().get_serializer_context()
ctx['request'] = self.request
try:
ctx['shipment'] = models.SalesOrderShipment.objects.get(
pk=self.kwargs.get('pk', None)
)
except Exception:
pass
ctx['shipment'] = self.get_shipment()
return ctx
@extend_schema(responses={200: common.serializers.TaskDetailSerializer})
def post(self, request, *args, **kwargs):
"""Override the post method to handle shipment completion."""
shipment = self.get_shipment()
serializer = self.get_serializer(shipment, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
task_id = shipment.complete_shipment(
request.user,
tracking_number=data.get('tracking_number', shipment.tracking_number),
invoice_number=data.get('invoice_number', shipment.invoice_number),
link=data.get('link', shipment.link),
shipment_date=data.get('shipment_date', None) or current_date(),
delivery_date=data.get('delivery_date', shipment.delivery_date),
)
response = common.serializers.TaskDetailSerializer.from_task(task_id).data
return Response(response, status=response['http_status'])
class ReturnOrderFilter(OrderFilter):
"""Custom API filters for the ReturnOrderList endpoint."""

View File

@@ -2418,54 +2418,45 @@ class SalesOrderShipment(
1. Update any stock items associated with this shipment
2. Update the "shipped" quantity of all associated line items
3. Set the "shipment_date" to now
Arguments:
user: The user who is completing this shipment
Returns:
task_id: The ID of the background task which is processing this shipment
"""
import order.tasks
# Check if the shipment can be completed (throw error if not)
self.check_can_complete()
# Update the "shipment" date
self.shipment_date = kwargs.get(
'shipment_date', InvenTree.helpers.current_date()
)
self.shipped_by = user
# Was a tracking number provided?
tracking_number = kwargs.get('tracking_number')
if tracking_number is not None:
if tracking_number := kwargs.get('tracking_number'):
self.tracking_number = tracking_number
# Was an invoice number provided?
invoice_number = kwargs.get('invoice_number')
if invoice_number is not None:
if invoice_number := kwargs.get('invoice_number'):
self.invoice_number = invoice_number
# Was a link provided?
link = kwargs.get('link')
if link is not None:
if link := kwargs.get('link'):
self.link = link
# Was a delivery date provided?
delivery_date = kwargs.get('delivery_date')
if delivery_date is not None:
self.delivery_date = delivery_date
self.save()
# Extract shipment date and delivery date from kwargs (if provided)
shipment_date = kwargs.get('shipment_date', InvenTree.helpers.current_date())
delivery_date = kwargs.get('delivery_date')
# Offload the "completion" of each line item to the background worker
# This may take some time, and we don't want to block the main thread
InvenTree.tasks.offload_task(
task_id = InvenTree.tasks.offload_task(
order.tasks.complete_sales_order_shipment,
shipment_id=self.pk,
user_id=user.pk if user else None,
self.pk,
user.pk if user else None,
shipment_date,
delivery_date=delivery_date,
group='sales_order',
)
trigger_event(SalesOrderEvents.SHIPMENT_COMPLETE, id=self.pk)
return task_id
class SalesOrderExtraLine(OrderExtraLine):

View File

@@ -27,13 +27,7 @@ from company.serializers import (
)
from generic.states.fields import InvenTreeCustomStatusSerializerMixin
from importer.registry import register_importer
from InvenTree.helpers import (
current_date,
extract_serial_numbers,
hash_barcode,
normalize,
str2bool,
)
from InvenTree.helpers import extract_serial_numbers, hash_barcode, normalize, str2bool
from InvenTree.mixins import DataImportExportSerializerMixin
from InvenTree.serializers import (
FilterableSerializerMixin,
@@ -1501,35 +1495,6 @@ class SalesOrderShipmentCompleteSerializer(serializers.ModelSerializer):
return data
def save(self):
"""Save the serializer to complete the SalesOrderShipment."""
shipment = self.context.get('shipment', None)
if not shipment:
return
data = self.validated_data
request = self.context.get('request')
user = request.user if request else None
# Extract shipping date (defaults to today's date)
now = current_date()
shipment_date = data.get('shipment_date', now)
if shipment_date is None:
# Shipment date should not be None - check above only
# checks if shipment_date exists in data
shipment_date = now
shipment.complete_shipment(
user,
tracking_number=data.get('tracking_number', shipment.tracking_number),
invoice_number=data.get('invoice_number', shipment.invoice_number),
link=data.get('link', shipment.link),
shipment_date=shipment_date,
delivery_date=data.get('delivery_date', shipment.delivery_date),
)
class SalesOrderShipmentAllocationItemSerializer(serializers.Serializer):
"""A serializer for allocating a single stock-item against a SalesOrder shipment."""

View File

@@ -1,6 +1,7 @@
"""Background tasks for the 'order' app."""
from datetime import datetime, timedelta
from typing import Optional
from django.contrib.auth.models import Group, User
from django.db import transaction
@@ -236,29 +237,39 @@ def check_overdue_return_orders():
@tracer.start_as_current_span('complete_sales_order_shipment')
def complete_sales_order_shipment(shipment_id: int, user_id: int) -> None:
def complete_sales_order_shipment(
shipment_id: int,
user_id: int,
shipment_date: str,
delivery_date: Optional[str] = None,
) -> None:
"""Complete allocations for a pending shipment against a SalesOrder.
Arguments:
shipment_id: The ID of the SalesOrderShipment object to complete
user_id: The ID of the user performing the completion action
shipment_date: The date that the shipment was completed (if None, then the current date is used)
delivery_date: The date that the shipment was delivered (optional)
At this stage, the shipment is assumed to be complete,
and we need to perform the required "processing" tasks.
"""
try:
shipment = order.models.SalesOrderShipment.objects.get(pk=shipment_id)
except Exception:
# Shipping object does not exist
logger.warning(
'Failed to complete shipment - no matching SalesOrderShipment for ID <%s>',
shipment_id,
)
return
try:
user = User.objects.get(pk=user_id)
except Exception:
user = None
# Do not handle any lookup errors here
# If the shipment cannot be found, then we want the task to fail (and retry later)
shipment = order.models.SalesOrderShipment.objects.get(pk=shipment_id)
user = User.objects.filter(pk=user_id).first() if user_id else None
logger.info('Completing SalesOrderShipment <%s>', shipment)
with transaction.atomic():
for allocation in shipment.allocations.all():
allocation.complete_allocation(user=user)
# Once all allocations have been completed, we can mark the shipment as complete
shipment.shipment_date = shipment_date or datetime.now().date()
shipment.delivery_date = delivery_date
shipment.shipped_by = user
shipment.save()
# Trigger event signalling that the shipment has been completed
trigger_event(SalesOrderEvents.SHIPMENT_COMPLETE, id=shipment.pk)

View File

@@ -1990,7 +1990,11 @@ class SalesOrderLineItemTest(OrderTest):
self.filter({'order': order_id, 'completed': 0}, 3)
# Finally, mark this shipment as 'shipped'
self.post(reverse('api-so-shipment-ship', kwargs={'pk': shipment.pk}), {})
self.post(
reverse('api-so-shipment-ship', kwargs={'pk': shipment.pk}),
{},
expected_code=200,
)
# Filter by 'completed' status
self.filter({'order': order_id, 'completed': 1}, 2)
@@ -2278,7 +2282,7 @@ class SalesOrderAllocateTest(OrderTest):
'shipment_date': '2020-12-05',
'delivery_date': '2023-12-05',
},
expected_code=201,
expected_code=200,
)
self.shipment.refresh_from_db()

View File

@@ -287,8 +287,15 @@ class SalesOrderTest(InvenTreeTestCase):
# Mark the shipments as complete
self.shipment.complete_shipment(None)
self.shipment.refresh_from_db()
self.assertTrue(self.shipment.is_complete())
# Check that each of the items have now been allocated to the customer
for allocation in self.shipment.allocations.all():
item = allocation.item
self.assertEqual(item.customer, self.order.customer)
self.assertEqual(item.sales_order, self.order)
# Now, should be OK to ship
result = self.order.ship_order(None)