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:
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user