2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-03-21 11:44:42 +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)

View File

@@ -24,7 +24,9 @@ import type {
ApiFormFieldSet,
ApiFormFieldType
} from '@lib/types/Forms';
import dayjs from 'dayjs';
import type { TableFieldRowProps } from '../components/forms/fields/TableField';
import useBackgroundTask from '../hooks/UseBackgroundTask';
import { useCreateApiFormModal, useEditApiFormModal } from '../hooks/UseForm';
import { useGlobalSettingsState } from '../states/SettingsStates';
import { useUserState } from '../states/UserState';
@@ -254,6 +256,45 @@ export function useUncheckShipmentForm({
});
}
export function useCompleteShipmentForm({
shipment,
onSuccess
}: {
shipment: any;
onSuccess: () => void;
}) {
const [taskId, setTaskId] = useState<string>('');
const completeShipmentFields = useSalesOrderShipmentCompleteFields({});
useBackgroundTask({
taskId: taskId,
message: t`Completing shipment`,
successMessage: t`Shipment completed successfully`,
onSuccess: onSuccess
});
return useCreateApiFormModal({
url: ApiEndpoints.sales_order_shipment_complete,
pk: shipment.pk,
title: t`Complete Shipment`,
fields: completeShipmentFields,
focus: 'tracking_number',
initialData: {
...shipment,
shipment_date: dayjs().format('YYYY-MM-DD')
},
successMessage: null,
onFormSuccess: (response: any) => {
if (response.task_id) {
setTaskId(response.task_id);
} else {
onSuccess();
}
}
});
}
function SalesOrderAllocateLineRow({
props,
record,
@@ -405,6 +446,7 @@ export function useAllocateToSalesOrderForm({
}
},
shipment: {
autoFill: true,
filters: {
shipped: false,
order_detail: true,

View File

@@ -13,7 +13,6 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { getDetailUrl } from '@lib/functions/Navigation';
import dayjs from 'dayjs';
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import {
@@ -40,12 +39,11 @@ import { RenderUser } from '../../components/render/User';
import { formatDate } from '../../defaults/formatters';
import {
useCheckShipmentForm,
useSalesOrderShipmentCompleteFields,
useCompleteShipmentForm,
useSalesOrderShipmentFields,
useUncheckShipmentForm
} from '../../forms/SalesOrderForms';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
@@ -304,19 +302,9 @@ export default function SalesOrderShipmentDetail() {
}
});
const completeShipmentFields = useSalesOrderShipmentCompleteFields({});
const completeShipment = useCreateApiFormModal({
url: ApiEndpoints.sales_order_shipment_complete,
pk: shipment.pk,
fields: completeShipmentFields,
title: t`Complete Shipment`,
focus: 'tracking_number',
initialData: {
...shipment,
shipment_date: dayjs().format('YYYY-MM-DD')
},
onFormSuccess: refreshShipment
const completeShipment = useCompleteShipmentForm({
shipment: shipment,
onSuccess: refreshShipment
});
const checkShipment = useCheckShipmentForm({

View File

@@ -292,6 +292,7 @@ export default function SalesOrderLineItemTable({
) : undefined,
initialData: initialData,
fields: allocateSerialFields,
successMessage: t`Stock allocated successfully`,
table: table
});

View File

@@ -21,9 +21,9 @@ import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import type { TableFilter } from '@lib/types/Filters';
import type { TableColumn } from '@lib/types/Tables';
import dayjs from 'dayjs';
import {
useCheckShipmentForm,
useCompleteShipmentForm,
useSalesOrderShipmentCompleteFields,
useSalesOrderShipmentFields,
useUncheckShipmentForm
@@ -77,6 +77,7 @@ export default function SalesOrderShipmentTable({
url: ApiEndpoints.sales_order_shipment_list,
fields: newShipmentFields,
title: t`Create Shipment`,
successMessage: t`Shipment created`,
table: table,
initialData: {
order: orderId
@@ -112,17 +113,9 @@ export default function SalesOrderShipmentTable({
}
});
const completeShipment = useCreateApiFormModal({
url: ApiEndpoints.sales_order_shipment_complete,
pk: selectedShipment.pk,
fields: completeShipmentFields,
title: t`Complete Shipment`,
table: table,
focus: 'tracking_number',
initialData: {
...selectedShipment,
shipment_date: dayjs().format('YYYY-MM-DD')
}
const completeShipment = useCompleteShipmentForm({
shipment: selectedShipment,
onSuccess: table.refreshTable
});
const tableColumns: TableColumn[] = useMemo(() => {

View File

@@ -417,7 +417,7 @@ export function StockItemTable({
// Navigate to the first result
navigate(getDetailUrl(ModelType.stockitem, response[0].pk));
},
successMessage: t`Stock item serialized`
successMessage: t`Stock item created`
});
const [partsToOrder, setPartsToOrder] = useState<any[]>([]);

View File

@@ -262,19 +262,22 @@ test('Parts - Details', async ({ browser }) => {
await page.getByText('Allocated to Sales Orders').waitFor();
await page.getByText('Can Build').waitFor();
await page.getByText('0 / 10').waitFor();
// The "allocated to sales order" quantity may vary, based on other tests
await page.getByText(/0 \/ \d+/).waitFor();
// Depending on the state of other tests, the "In Production" value may vary
// This could be either 4 / 49, or 5 / 49
await page.getByText(/[4|5] \/ \d+/).waitFor();
// Badges
await page.getByText('Required: 10').waitFor();
await page.getByText(/Required: \d+/).waitFor();
await page.getByText('No Stock').waitFor();
await page.getByText(/In Production: [4|5]/).waitFor();
await page.getByText('Creation Date').waitFor();
await page.getByText('2022-04-29').waitFor();
await page.getByText('Latest Serial Number').waitFor();
});
test('Parts - Requirements', async ({ browser }) => {

View File

@@ -5,6 +5,7 @@ import {
clickOnRowMenu,
globalSearch,
loadTab,
navigate,
setTableChoiceFilter,
showCalendarView,
showParametricView,
@@ -238,6 +239,77 @@ test('Sales Orders - Shipments', async ({ browser }) => {
.click();
});
// Complete a shipment against a sales order
test('Sales Orders - Complete Shipment', async ({ browser }) => {
const page = await doCachedLogin(browser, {
url: 'part/113/stock'
});
const serialNumber = `SN${Math.floor(Math.random() * 100000)}`;
const shipmentReference = `SHIP-${Math.floor(Math.random() * 100000)}`;
// First create some stock to allocate
await page
.getByRole('button', { name: 'action-button-add-stock-item' })
.click();
await page
.getByRole('textbox', { name: 'text-field-serial_numbers' })
.fill(serialNumber);
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Stock item created').first().waitFor();
// Navigate to the sales order and create a new shipment
await navigate(page, '/sales/sales-order/7/shipments');
await page
.getByRole('button', { name: 'action-button-add-shipment' })
.click();
await page
.getByLabel('text-field-reference', { exact: true })
.fill(shipmentReference);
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Shipment created').first().waitFor();
// Back to the "line items" tab to allocate stock
await loadTab(page, 'Line Items');
const cell = await page.getByRole('cell', { name: 'MAST', exact: true });
await clickOnRowMenu(cell);
// Allocate 1 item based on serial number
await page.getByRole('menuitem', { name: 'Allocate serials' }).click();
await page.getByRole('textbox', { name: 'number-field-quantity' }).fill('1');
await page
.getByRole('textbox', { name: 'text-field-serial_numbers' })
.fill(serialNumber);
await page.getByLabel('related-field-shipment').fill(shipmentReference);
await page.getByText(`SO0007Shipment ${shipmentReference}`).click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Stock allocated successfully').first().waitFor();
// Navigate to the shipment and mark it as "shipped"
await loadTab(page, 'Shipments');
await page.getByRole('cell', { name: shipmentReference }).click();
await page.getByText(shipmentReference).first().waitFor();
await page.getByText('Pending').first().waitFor();
await loadTab(page, 'Allocated Stock');
// Check that the serial number is allocated as expected
await page.getByRole('cell', { name: serialNumber }).waitFor();
await page.getByRole('button', { name: 'Send Shipment' }).click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Completing shipment').first().waitFor();
await page.getByText('Shipment completed').first().waitFor();
await page.getByText('Shipped', { exact: true }).first().waitFor();
// Finally, navigate to the stock item and check it has been allocated to the customer
await page.getByRole('cell', { name: serialNumber }).click();
await page.waitForLoadState('networkidle');
await page.getByText('Unavailable').first().waitFor();
await page.getByRole('link', { name: 'SO0007' }).waitFor();
await page.getByRole('cell', { name: 'Customer D' }).waitFor();
});
test('Sales Orders - Duplicate', async ({ browser }) => {
const page = await doCachedLogin(browser, {
url: 'sales/sales-order/14/detail'