diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 245754ba28..b8a1487ad4 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index b8e214a0ac..bed41d14a1 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -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.""" diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index 5b6cbbceb6..b2b72a5b5a 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -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): diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index c79382e429..a2f179425e 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -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.""" diff --git a/src/backend/InvenTree/order/tasks.py b/src/backend/InvenTree/order/tasks.py index fea6891b34..d9065c2d20 100644 --- a/src/backend/InvenTree/order/tasks.py +++ b/src/backend/InvenTree/order/tasks.py @@ -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) diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index 7952f29eac..3151570a29 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -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() diff --git a/src/backend/InvenTree/order/test_sales_order.py b/src/backend/InvenTree/order/test_sales_order.py index 45f6a10e50..0186cfd90c 100644 --- a/src/backend/InvenTree/order/test_sales_order.py +++ b/src/backend/InvenTree/order/test_sales_order.py @@ -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) diff --git a/src/frontend/src/forms/SalesOrderForms.tsx b/src/frontend/src/forms/SalesOrderForms.tsx index 1a060df877..b4b4fa4c4e 100644 --- a/src/frontend/src/forms/SalesOrderForms.tsx +++ b/src/frontend/src/forms/SalesOrderForms.tsx @@ -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(''); + + 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, diff --git a/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx b/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx index f69ee2b6ae..2ddd8e5077 100644 --- a/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx +++ b/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx @@ -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({ diff --git a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx index a1391feca3..fd8004fa4e 100644 --- a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx +++ b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx @@ -292,6 +292,7 @@ export default function SalesOrderLineItemTable({ ) : undefined, initialData: initialData, fields: allocateSerialFields, + successMessage: t`Stock allocated successfully`, table: table }); diff --git a/src/frontend/src/tables/sales/SalesOrderShipmentTable.tsx b/src/frontend/src/tables/sales/SalesOrderShipmentTable.tsx index deed41fff1..6de278adf2 100644 --- a/src/frontend/src/tables/sales/SalesOrderShipmentTable.tsx +++ b/src/frontend/src/tables/sales/SalesOrderShipmentTable.tsx @@ -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(() => { diff --git a/src/frontend/src/tables/stock/StockItemTable.tsx b/src/frontend/src/tables/stock/StockItemTable.tsx index 04d37dab84..4f5f352e46 100644 --- a/src/frontend/src/tables/stock/StockItemTable.tsx +++ b/src/frontend/src/tables/stock/StockItemTable.tsx @@ -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([]); diff --git a/src/frontend/tests/pages/pui_part.spec.ts b/src/frontend/tests/pages/pui_part.spec.ts index 368e4ebbac..b3516deba0 100644 --- a/src/frontend/tests/pages/pui_part.spec.ts +++ b/src/frontend/tests/pages/pui_part.spec.ts @@ -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 }) => { diff --git a/src/frontend/tests/pages/pui_sales_order.spec.ts b/src/frontend/tests/pages/pui_sales_order.spec.ts index 749c23684e..25d9a464f2 100644 --- a/src/frontend/tests/pages/pui_sales_order.spec.ts +++ b/src/frontend/tests/pages/pui_sales_order.spec.ts @@ -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'