mirror of
https://github.com/inventree/InvenTree.git
synced 2026-04-04 18:40:55 +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 information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
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
|
- 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
|
- 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.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import extend_schema, extend_schema_field
|
from drf_spectacular.utils import extend_schema, extend_schema_field
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
from rest_framework.exceptions import NotFound
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
import build.models
|
import build.models
|
||||||
@@ -36,7 +37,7 @@ from InvenTree.api import (
|
|||||||
)
|
)
|
||||||
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
|
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
|
||||||
from InvenTree.filters import SEARCH_ORDER_FILTER, InvenTreeDateFilter
|
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.helpers_model import construct_absolute_url, get_base_url
|
||||||
from InvenTree.mixins import (
|
from InvenTree.mixins import (
|
||||||
CreateAPI,
|
CreateAPI,
|
||||||
@@ -1426,20 +1427,46 @@ class SalesOrderShipmentComplete(CreateAPI):
|
|||||||
queryset = models.SalesOrderShipment.objects.all()
|
queryset = models.SalesOrderShipment.objects.all()
|
||||||
serializer_class = serializers.SalesOrderShipmentCompleteSerializer
|
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):
|
def get_serializer_context(self):
|
||||||
"""Pass the request object to the serializer."""
|
"""Pass the request object to the serializer."""
|
||||||
ctx = super().get_serializer_context()
|
ctx = super().get_serializer_context()
|
||||||
ctx['request'] = self.request
|
ctx['request'] = self.request
|
||||||
|
ctx['shipment'] = self.get_shipment()
|
||||||
try:
|
|
||||||
ctx['shipment'] = models.SalesOrderShipment.objects.get(
|
|
||||||
pk=self.kwargs.get('pk', None)
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return ctx
|
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):
|
class ReturnOrderFilter(OrderFilter):
|
||||||
"""Custom API filters for the ReturnOrderList endpoint."""
|
"""Custom API filters for the ReturnOrderList endpoint."""
|
||||||
|
|||||||
@@ -2418,54 +2418,45 @@ class SalesOrderShipment(
|
|||||||
1. Update any stock items associated with this shipment
|
1. Update any stock items associated with this shipment
|
||||||
2. Update the "shipped" quantity of all associated line items
|
2. Update the "shipped" quantity of all associated line items
|
||||||
3. Set the "shipment_date" to now
|
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
|
import order.tasks
|
||||||
|
|
||||||
# Check if the shipment can be completed (throw error if not)
|
# Check if the shipment can be completed (throw error if not)
|
||||||
self.check_can_complete()
|
self.check_can_complete()
|
||||||
|
|
||||||
# Update the "shipment" date
|
if tracking_number := kwargs.get('tracking_number'):
|
||||||
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:
|
|
||||||
self.tracking_number = tracking_number
|
self.tracking_number = tracking_number
|
||||||
|
|
||||||
# Was an invoice number provided?
|
if invoice_number := kwargs.get('invoice_number'):
|
||||||
invoice_number = kwargs.get('invoice_number')
|
|
||||||
|
|
||||||
if invoice_number is not None:
|
|
||||||
self.invoice_number = invoice_number
|
self.invoice_number = invoice_number
|
||||||
|
|
||||||
# Was a link provided?
|
if link := kwargs.get('link'):
|
||||||
link = kwargs.get('link')
|
|
||||||
|
|
||||||
if link is not None:
|
|
||||||
self.link = 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()
|
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
|
# 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
|
# 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,
|
order.tasks.complete_sales_order_shipment,
|
||||||
shipment_id=self.pk,
|
self.pk,
|
||||||
user_id=user.pk if user else None,
|
user.pk if user else None,
|
||||||
|
shipment_date,
|
||||||
|
delivery_date=delivery_date,
|
||||||
group='sales_order',
|
group='sales_order',
|
||||||
)
|
)
|
||||||
|
|
||||||
trigger_event(SalesOrderEvents.SHIPMENT_COMPLETE, id=self.pk)
|
return task_id
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderExtraLine(OrderExtraLine):
|
class SalesOrderExtraLine(OrderExtraLine):
|
||||||
|
|||||||
@@ -27,13 +27,7 @@ from company.serializers import (
|
|||||||
)
|
)
|
||||||
from generic.states.fields import InvenTreeCustomStatusSerializerMixin
|
from generic.states.fields import InvenTreeCustomStatusSerializerMixin
|
||||||
from importer.registry import register_importer
|
from importer.registry import register_importer
|
||||||
from InvenTree.helpers import (
|
from InvenTree.helpers import extract_serial_numbers, hash_barcode, normalize, str2bool
|
||||||
current_date,
|
|
||||||
extract_serial_numbers,
|
|
||||||
hash_barcode,
|
|
||||||
normalize,
|
|
||||||
str2bool,
|
|
||||||
)
|
|
||||||
from InvenTree.mixins import DataImportExportSerializerMixin
|
from InvenTree.mixins import DataImportExportSerializerMixin
|
||||||
from InvenTree.serializers import (
|
from InvenTree.serializers import (
|
||||||
FilterableSerializerMixin,
|
FilterableSerializerMixin,
|
||||||
@@ -1501,35 +1495,6 @@ class SalesOrderShipmentCompleteSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
return data
|
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):
|
class SalesOrderShipmentAllocationItemSerializer(serializers.Serializer):
|
||||||
"""A serializer for allocating a single stock-item against a SalesOrder shipment."""
|
"""A serializer for allocating a single stock-item against a SalesOrder shipment."""
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Background tasks for the 'order' app."""
|
"""Background tasks for the 'order' app."""
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from django.contrib.auth.models import Group, User
|
from django.contrib.auth.models import Group, User
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
@@ -236,29 +237,39 @@ def check_overdue_return_orders():
|
|||||||
|
|
||||||
|
|
||||||
@tracer.start_as_current_span('complete_sales_order_shipment')
|
@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.
|
"""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,
|
At this stage, the shipment is assumed to be complete,
|
||||||
and we need to perform the required "processing" tasks.
|
and we need to perform the required "processing" tasks.
|
||||||
"""
|
"""
|
||||||
try:
|
# Do not handle any lookup errors here
|
||||||
shipment = order.models.SalesOrderShipment.objects.get(pk=shipment_id)
|
# If the shipment cannot be found, then we want the task to fail (and retry later)
|
||||||
except Exception:
|
shipment = order.models.SalesOrderShipment.objects.get(pk=shipment_id)
|
||||||
# Shipping object does not exist
|
user = User.objects.filter(pk=user_id).first() if user_id else None
|
||||||
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
|
|
||||||
|
|
||||||
logger.info('Completing SalesOrderShipment <%s>', shipment)
|
logger.info('Completing SalesOrderShipment <%s>', shipment)
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
for allocation in shipment.allocations.all():
|
for allocation in shipment.allocations.all():
|
||||||
allocation.complete_allocation(user=user)
|
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)
|
self.filter({'order': order_id, 'completed': 0}, 3)
|
||||||
|
|
||||||
# Finally, mark this shipment as 'shipped'
|
# 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
|
# Filter by 'completed' status
|
||||||
self.filter({'order': order_id, 'completed': 1}, 2)
|
self.filter({'order': order_id, 'completed': 1}, 2)
|
||||||
@@ -2278,7 +2282,7 @@ class SalesOrderAllocateTest(OrderTest):
|
|||||||
'shipment_date': '2020-12-05',
|
'shipment_date': '2020-12-05',
|
||||||
'delivery_date': '2023-12-05',
|
'delivery_date': '2023-12-05',
|
||||||
},
|
},
|
||||||
expected_code=201,
|
expected_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.shipment.refresh_from_db()
|
self.shipment.refresh_from_db()
|
||||||
|
|||||||
@@ -287,8 +287,15 @@ class SalesOrderTest(InvenTreeTestCase):
|
|||||||
|
|
||||||
# Mark the shipments as complete
|
# Mark the shipments as complete
|
||||||
self.shipment.complete_shipment(None)
|
self.shipment.complete_shipment(None)
|
||||||
|
self.shipment.refresh_from_db()
|
||||||
self.assertTrue(self.shipment.is_complete())
|
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
|
# Now, should be OK to ship
|
||||||
result = self.order.ship_order(None)
|
result = self.order.ship_order(None)
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ import type {
|
|||||||
ApiFormFieldSet,
|
ApiFormFieldSet,
|
||||||
ApiFormFieldType
|
ApiFormFieldType
|
||||||
} from '@lib/types/Forms';
|
} from '@lib/types/Forms';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import type { TableFieldRowProps } from '../components/forms/fields/TableField';
|
import type { TableFieldRowProps } from '../components/forms/fields/TableField';
|
||||||
|
import useBackgroundTask from '../hooks/UseBackgroundTask';
|
||||||
import { useCreateApiFormModal, useEditApiFormModal } from '../hooks/UseForm';
|
import { useCreateApiFormModal, useEditApiFormModal } from '../hooks/UseForm';
|
||||||
import { useGlobalSettingsState } from '../states/SettingsStates';
|
import { useGlobalSettingsState } from '../states/SettingsStates';
|
||||||
import { useUserState } from '../states/UserState';
|
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({
|
function SalesOrderAllocateLineRow({
|
||||||
props,
|
props,
|
||||||
record,
|
record,
|
||||||
@@ -405,6 +446,7 @@ export function useAllocateToSalesOrderForm({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
shipment: {
|
shipment: {
|
||||||
|
autoFill: true,
|
||||||
filters: {
|
filters: {
|
||||||
shipped: false,
|
shipped: false,
|
||||||
order_detail: true,
|
order_detail: true,
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
|||||||
import { ModelType } from '@lib/enums/ModelType';
|
import { ModelType } from '@lib/enums/ModelType';
|
||||||
import { UserRoles } from '@lib/enums/Roles';
|
import { UserRoles } from '@lib/enums/Roles';
|
||||||
import { getDetailUrl } from '@lib/functions/Navigation';
|
import { getDetailUrl } from '@lib/functions/Navigation';
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
|
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
|
||||||
import { PrintingActions } from '../../components/buttons/PrintingActions';
|
import { PrintingActions } from '../../components/buttons/PrintingActions';
|
||||||
import {
|
import {
|
||||||
@@ -40,12 +39,11 @@ import { RenderUser } from '../../components/render/User';
|
|||||||
import { formatDate } from '../../defaults/formatters';
|
import { formatDate } from '../../defaults/formatters';
|
||||||
import {
|
import {
|
||||||
useCheckShipmentForm,
|
useCheckShipmentForm,
|
||||||
useSalesOrderShipmentCompleteFields,
|
useCompleteShipmentForm,
|
||||||
useSalesOrderShipmentFields,
|
useSalesOrderShipmentFields,
|
||||||
useUncheckShipmentForm
|
useUncheckShipmentForm
|
||||||
} from '../../forms/SalesOrderForms';
|
} from '../../forms/SalesOrderForms';
|
||||||
import {
|
import {
|
||||||
useCreateApiFormModal,
|
|
||||||
useDeleteApiFormModal,
|
useDeleteApiFormModal,
|
||||||
useEditApiFormModal
|
useEditApiFormModal
|
||||||
} from '../../hooks/UseForm';
|
} from '../../hooks/UseForm';
|
||||||
@@ -304,19 +302,9 @@ export default function SalesOrderShipmentDetail() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const completeShipmentFields = useSalesOrderShipmentCompleteFields({});
|
const completeShipment = useCompleteShipmentForm({
|
||||||
|
shipment: shipment,
|
||||||
const completeShipment = useCreateApiFormModal({
|
onSuccess: refreshShipment
|
||||||
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 checkShipment = useCheckShipmentForm({
|
const checkShipment = useCheckShipmentForm({
|
||||||
|
|||||||
@@ -292,6 +292,7 @@ export default function SalesOrderLineItemTable({
|
|||||||
) : undefined,
|
) : undefined,
|
||||||
initialData: initialData,
|
initialData: initialData,
|
||||||
fields: allocateSerialFields,
|
fields: allocateSerialFields,
|
||||||
|
successMessage: t`Stock allocated successfully`,
|
||||||
table: table
|
table: table
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ import { UserRoles } from '@lib/enums/Roles';
|
|||||||
import { apiUrl } from '@lib/functions/Api';
|
import { apiUrl } from '@lib/functions/Api';
|
||||||
import type { TableFilter } from '@lib/types/Filters';
|
import type { TableFilter } from '@lib/types/Filters';
|
||||||
import type { TableColumn } from '@lib/types/Tables';
|
import type { TableColumn } from '@lib/types/Tables';
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import {
|
import {
|
||||||
useCheckShipmentForm,
|
useCheckShipmentForm,
|
||||||
|
useCompleteShipmentForm,
|
||||||
useSalesOrderShipmentCompleteFields,
|
useSalesOrderShipmentCompleteFields,
|
||||||
useSalesOrderShipmentFields,
|
useSalesOrderShipmentFields,
|
||||||
useUncheckShipmentForm
|
useUncheckShipmentForm
|
||||||
@@ -77,6 +77,7 @@ export default function SalesOrderShipmentTable({
|
|||||||
url: ApiEndpoints.sales_order_shipment_list,
|
url: ApiEndpoints.sales_order_shipment_list,
|
||||||
fields: newShipmentFields,
|
fields: newShipmentFields,
|
||||||
title: t`Create Shipment`,
|
title: t`Create Shipment`,
|
||||||
|
successMessage: t`Shipment created`,
|
||||||
table: table,
|
table: table,
|
||||||
initialData: {
|
initialData: {
|
||||||
order: orderId
|
order: orderId
|
||||||
@@ -112,17 +113,9 @@ export default function SalesOrderShipmentTable({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const completeShipment = useCreateApiFormModal({
|
const completeShipment = useCompleteShipmentForm({
|
||||||
url: ApiEndpoints.sales_order_shipment_complete,
|
shipment: selectedShipment,
|
||||||
pk: selectedShipment.pk,
|
onSuccess: table.refreshTable
|
||||||
fields: completeShipmentFields,
|
|
||||||
title: t`Complete Shipment`,
|
|
||||||
table: table,
|
|
||||||
focus: 'tracking_number',
|
|
||||||
initialData: {
|
|
||||||
...selectedShipment,
|
|
||||||
shipment_date: dayjs().format('YYYY-MM-DD')
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const tableColumns: TableColumn[] = useMemo(() => {
|
const tableColumns: TableColumn[] = useMemo(() => {
|
||||||
|
|||||||
@@ -417,7 +417,7 @@ export function StockItemTable({
|
|||||||
// Navigate to the first result
|
// Navigate to the first result
|
||||||
navigate(getDetailUrl(ModelType.stockitem, response[0].pk));
|
navigate(getDetailUrl(ModelType.stockitem, response[0].pk));
|
||||||
},
|
},
|
||||||
successMessage: t`Stock item serialized`
|
successMessage: t`Stock item created`
|
||||||
});
|
});
|
||||||
|
|
||||||
const [partsToOrder, setPartsToOrder] = useState<any[]>([]);
|
const [partsToOrder, setPartsToOrder] = useState<any[]>([]);
|
||||||
|
|||||||
@@ -262,19 +262,22 @@ test('Parts - Details', async ({ browser }) => {
|
|||||||
await page.getByText('Allocated to Sales Orders').waitFor();
|
await page.getByText('Allocated to Sales Orders').waitFor();
|
||||||
await page.getByText('Can Build').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
|
// Depending on the state of other tests, the "In Production" value may vary
|
||||||
// This could be either 4 / 49, or 5 / 49
|
// This could be either 4 / 49, or 5 / 49
|
||||||
await page.getByText(/[4|5] \/ \d+/).waitFor();
|
await page.getByText(/[4|5] \/ \d+/).waitFor();
|
||||||
|
|
||||||
// Badges
|
// Badges
|
||||||
await page.getByText('Required: 10').waitFor();
|
await page.getByText(/Required: \d+/).waitFor();
|
||||||
await page.getByText('No Stock').waitFor();
|
await page.getByText('No Stock').waitFor();
|
||||||
await page.getByText(/In Production: [4|5]/).waitFor();
|
await page.getByText(/In Production: [4|5]/).waitFor();
|
||||||
|
|
||||||
await page.getByText('Creation Date').waitFor();
|
await page.getByText('Creation Date').waitFor();
|
||||||
await page.getByText('2022-04-29').waitFor();
|
await page.getByText('2022-04-29').waitFor();
|
||||||
|
|
||||||
|
await page.getByText('Latest Serial Number').waitFor();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Parts - Requirements', async ({ browser }) => {
|
test('Parts - Requirements', async ({ browser }) => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
clickOnRowMenu,
|
clickOnRowMenu,
|
||||||
globalSearch,
|
globalSearch,
|
||||||
loadTab,
|
loadTab,
|
||||||
|
navigate,
|
||||||
setTableChoiceFilter,
|
setTableChoiceFilter,
|
||||||
showCalendarView,
|
showCalendarView,
|
||||||
showParametricView,
|
showParametricView,
|
||||||
@@ -238,6 +239,77 @@ test('Sales Orders - Shipments', async ({ browser }) => {
|
|||||||
.click();
|
.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 }) => {
|
test('Sales Orders - Duplicate', async ({ browser }) => {
|
||||||
const page = await doCachedLogin(browser, {
|
const page = await doCachedLogin(browser, {
|
||||||
url: 'sales/sales-order/14/detail'
|
url: 'sales/sales-order/14/detail'
|
||||||
|
|||||||
Reference in New Issue
Block a user