diff --git a/CHANGELOG.md b/CHANGELOG.md index 9941037275..c372c0f4bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Expose stock adjustment forms to the UI plugin context in [#10584](https://github.com/inventree/InvenTree/pull/10584) - Allow stock adjustments for "in production" items in [#10600](https://github.com/inventree/InvenTree/pull/10600) - Adds optional shipping address against individual sales order shipments in [#10650](https://github.com/inventree/InvenTree/pull/10650) +- Adds UI elements to "check" and "uncheck" sales order shipments in [#10654](https://github.com/inventree/InvenTree/pull/10654) ### Changed diff --git a/docs/docs/assets/images/order/so_shipment_check.png b/docs/docs/assets/images/order/so_shipment_check.png new file mode 100644 index 0000000000..35d489160d Binary files /dev/null and b/docs/docs/assets/images/order/so_shipment_check.png differ diff --git a/docs/docs/sales/sales_order.md b/docs/docs/sales/sales_order.md index b85703c2a3..859ef78bba 100644 --- a/docs/docs/sales/sales_order.md +++ b/docs/docs/sales/sales_order.md @@ -110,6 +110,18 @@ After shipments were created, user can either: During the allocation process, user is required to select the desired shipment that will contain the stock items. +### Check Shipment + +Shipments can be marked as "checked" to indicate that the items in the shipment has been verified. To mark a shipment as "checked", open the shipment actions menu, and select the "Check" action: + +{{ image("order/so_shipment_check.png", "Check shipment") }} + +The shipment will be marked as checked by the current user. + +### Uncheck Shipment + +If the shipment requires further verification after being marked as "checked", it can be marked as "unchecked" in a similar manner. + ### Complete Shipment To complete a shipment, click on the {{ icon("truck-loading") }} Pending Shipments tab then click on {{ icon("truck-delivery") }} button shown in the shipment table. @@ -221,3 +233,4 @@ The following [global settings](../settings/global.md) are available for sales o {{ globalsetting("SALESORDER_DEFAULT_SHIPMENT") }} {{ globalsetting("SALESORDER_EDIT_COMPLETED_ORDERS") }} {{ globalsetting("SALESORDER_SHIP_COMPLETE") }} +{{ globalsetting("SALESORDER_SHIPMENT_REQUIRES_CHECK") }} diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 7bbaf7d9e9..992b9d44d9 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,16 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 416 +INVENTREE_API_VERSION = 417 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v417 -> 2025-10-22 : https://github.com/inventree/InvenTree/pull/10654 + - Adds "checked" filter to SalesOrderShipment API endpoint + - Adds "order_status" filter to SalesOrdereShipment API endpoint + - Adds "order_outstanding" filter to SalesOrderShipment API endpoint + v416 -> 2025-10-22 : https://github.com/inventree/InvenTree/pull/10651 - Add missing nullable to make price_breaks (from v412) optional diff --git a/src/backend/InvenTree/common/setting/system.py b/src/backend/InvenTree/common/setting/system.py index 305db33abf..066c8614cb 100644 --- a/src/backend/InvenTree/common/setting/system.py +++ b/src/backend/InvenTree/common/setting/system.py @@ -854,6 +854,14 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'default': False, 'validator': bool, }, + 'SALESORDER_SHIPMENT_REQUIRES_CHECK': { + 'name': _('Shipment Requires Checking'), + 'description': _( + 'Prevent completion of shipments until items have been checked' + ), + 'default': False, + 'validator': bool, + }, 'SALESORDER_SHIP_COMPLETE': { 'name': _('Mark Shipped Orders as Complete'), 'description': _( diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index 470550e6eb..ddf7dadb57 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -1335,6 +1335,14 @@ class SalesOrderShipmentFilter(FilterSet): model = models.SalesOrderShipment fields = ['order'] + checked = rest_filters.BooleanFilter(label='checked', method='filter_checked') + + def filter_checked(self, queryset, name, value): + """Filter SalesOrderShipment list by 'checked' status (boolean).""" + if str2bool(value): + return queryset.exclude(checked_by=None) + return queryset.filter(checked_by=None) + shipped = rest_filters.BooleanFilter(label='shipped', method='filter_shipped') def filter_shipped(self, queryset, name, value): @@ -1351,6 +1359,27 @@ class SalesOrderShipmentFilter(FilterSet): return queryset.exclude(delivery_date=None) return queryset.filter(delivery_date=None) + order_outstanding = rest_filters.BooleanFilter( + label=_('Order Outstanding'), method='filter_order_outstanding' + ) + + def filter_order_outstanding(self, queryset, name, value): + """Filter by whether the order is 'outstanding' or not.""" + if str2bool(value): + return queryset.filter(order__status__in=SalesOrderStatusGroups.OPEN) + return queryset.exclude(order__status__in=SalesOrderStatusGroups.OPEN) + + order_status = rest_filters.NumberFilter( + label=_('Order Status'), method='filter_order_status' + ) + + def filter_order_status(self, queryset, name, value): + """Filter by linked SalesOrderrder status.""" + q1 = Q(order__status=value, order__status_custom_key__isnull=True) + q2 = Q(order__status_custom_key=value) + + return queryset.filter(q1 | q2).distinct() + class SalesOrderShipmentMixin: """Mixin class for SalesOrderShipment endpoints.""" diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index f1c4bb0c67..6126830b1c 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -336,7 +336,7 @@ class Order( A locked order cannot be modified after it has been completed. - Args: + Arguments: db: If True, check with the database. If False, check the instance (default False). """ if not self.check_complete(db=db): @@ -351,7 +351,7 @@ class Order( def check_complete(self, db: bool = False) -> bool: """Check if this order is 'complete'. - Args: + Arguments: db: If True, check with the database. If False, check the instance (default False). """ status = self.get_db_instance().status if db else self.status @@ -560,12 +560,12 @@ class PurchaseOrder(TotalPriceMixin, Order): """Return report context data for this PurchaseOrder.""" return {**super().report_context(), 'supplier': self.supplier} - def get_absolute_url(self): + def get_absolute_url(self) -> str: """Get the 'web' URL for this order.""" return pui_url(f'/purchasing/purchase-order/{self.pk}') @staticmethod - def get_api_url(): + def get_api_url() -> str: """Return the API URL associated with the PurchaseOrder model.""" return reverse('api-po-list') @@ -584,7 +584,7 @@ class PurchaseOrder(TotalPriceMixin, Order): return defaults @classmethod - def barcode_model_type_code(cls): + def barcode_model_type_code(cls) -> str: """Return the associated barcode model type code for this model.""" return 'PO' @@ -805,10 +805,10 @@ class PurchaseOrder(TotalPriceMixin, Order): @transaction.atomic def issue_order(self): """Equivalent to 'place_order'.""" - self.place_order() + return self.place_order() @property - def can_issue(self): + def can_issue(self) -> bool: """Return True if this order can be issued.""" return self.status in [ PurchaseOrderStatus.PENDING.value, @@ -844,17 +844,17 @@ class PurchaseOrder(TotalPriceMixin, Order): ) @property - def is_pending(self): + def is_pending(self) -> bool: """Return True if the PurchaseOrder is 'pending'.""" return self.status == PurchaseOrderStatus.PENDING.value @property - def is_open(self): + def is_open(self) -> bool: """Return True if the PurchaseOrder is 'open'.""" return self.status in PurchaseOrderStatusGroups.OPEN @property - def can_cancel(self): + def can_cancel(self) -> bool: """A PurchaseOrder can only be cancelled under the following circumstances. - Status is PLACED @@ -880,7 +880,7 @@ class PurchaseOrder(TotalPriceMixin, Order): ) @property - def can_hold(self): + def can_hold(self) -> bool: """Return True if this order can be placed on hold.""" return self.status in [ PurchaseOrderStatus.PENDING.value, @@ -897,34 +897,34 @@ class PurchaseOrder(TotalPriceMixin, Order): # endregion - def pending_line_items(self): + def pending_line_items(self) -> QuerySet: """Return a list of pending line items for this order. Any line item where 'received' < 'quantity' will be returned. """ return self.lines.filter(quantity__gt=F('received')) - def completed_line_items(self): + def completed_line_items(self) -> QuerySet: """Return a list of completed line items against this order.""" return self.lines.filter(quantity__lte=F('received')) @property - def line_count(self): + def line_count(self) -> int: """Return the total number of line items associated with this order.""" return self.lines.count() @property - def completed_line_count(self): + def completed_line_count(self) -> int: """Return the number of complete line items associated with this order.""" return self.completed_line_items().count() @property - def pending_line_count(self): + def pending_line_count(self) -> int: """Return the number of pending line items associated with this order.""" return self.pending_line_items().count() @property - def is_complete(self): + def is_complete(self) -> bool: """Return True if all line items have been received.""" return self.pending_line_items().count() == 0 @@ -1247,12 +1247,12 @@ class SalesOrder(TotalPriceMixin, Order): """Generate report context data for this SalesOrder.""" return {**super().report_context(), 'customer': self.customer} - def get_absolute_url(self): + def get_absolute_url(self) -> str: """Get the 'web' URL for this order.""" return pui_url(f'/sales/sales-order/{self.pk}') @staticmethod - def get_api_url(): + def get_api_url() -> str: """Return the API URL associated with the SalesOrder model.""" return reverse('api-so-list') @@ -1262,14 +1262,14 @@ class SalesOrder(TotalPriceMixin, Order): return SalesOrderStatusGroups @classmethod - def api_defaults(cls, request=None): + def api_defaults(cls, request=None) -> dict: """Return default values for this model when issuing an API OPTIONS request.""" defaults = {'reference': order.validators.generate_next_sales_order_reference()} return defaults @classmethod - def barcode_model_type_code(cls): + def barcode_model_type_code(cls) -> str: """Return the associated barcode model type code for this model.""" return 'SO' @@ -1326,7 +1326,7 @@ class SalesOrder(TotalPriceMixin, Order): ) @property - def status_text(self): + def status_text(self) -> str: """Return the text representation of the status field.""" return SalesOrderStatus.text(self.status) @@ -1351,38 +1351,45 @@ class SalesOrder(TotalPriceMixin, Order): ) @property - def is_pending(self): + def is_pending(self) -> bool: """Return True if this order is 'pending'.""" return self.status == SalesOrderStatus.PENDING @property - def is_open(self): + def is_open(self) -> bool: """Return True if this order is 'open' (either 'pending' or 'in_progress').""" return self.status in SalesOrderStatusGroups.OPEN @property - def stock_allocations(self): + def stock_allocations(self) -> QuerySet: """Return a queryset containing all allocations for this order.""" return SalesOrderAllocation.objects.filter( line__in=[line.pk for line in self.lines.all()] ) - def is_fully_allocated(self): + def is_fully_allocated(self) -> bool: """Return True if all line items are fully allocated.""" return all(line.is_fully_allocated() for line in self.lines.all()) - def is_overallocated(self): + def is_overallocated(self) -> bool: """Return true if any lines in the order are over-allocated.""" return any(line.is_overallocated() for line in self.lines.all()) - def is_completed(self): + def is_completed(self) -> bool: """Check if this order is "shipped" (all line items delivered).""" return all(line.is_completed() for line in self.lines.all()) - def can_complete(self, raise_error=False, allow_incomplete_lines=False): + def can_complete( + self, raise_error: bool = False, allow_incomplete_lines: bool = False + ) -> bool: """Test if this SalesOrder can be completed. - Throws a ValidationError if cannot be completed. + Arguments: + raise_error: If True, raise ValidationError if the order cannot be completed + allow_incomplete_lines: If True, allow incomplete line items when completing the order + + Raises: + ValidationError: If the order cannot be completed, and raise_error is True """ try: if self.status == SalesOrderStatus.COMPLETE.value: @@ -1424,7 +1431,7 @@ class SalesOrder(TotalPriceMixin, Order): self.issue_order() @property - def can_issue(self): + def can_issue(self) -> bool: """Return True if this order can be issued.""" return self.status in [ SalesOrderStatus.PENDING.value, @@ -1450,7 +1457,7 @@ class SalesOrder(TotalPriceMixin, Order): ) @property - def can_hold(self): + def can_hold(self) -> bool: """Return True if this order can be placed on hold.""" return self.status in [ SalesOrderStatus.PENDING.value, @@ -1497,7 +1504,7 @@ class SalesOrder(TotalPriceMixin, Order): return True @property - def can_cancel(self): + def can_cancel(self) -> bool: """Return True if this order can be cancelled.""" return self.is_open @@ -1579,15 +1586,15 @@ class SalesOrder(TotalPriceMixin, Order): # endregion @property - def line_count(self): + def line_count(self) -> int: """Return the total number of lines associated with this order.""" return self.lines.count() - def completed_line_items(self): + def completed_line_items(self) -> QuerySet: """Return a queryset of the completed line items for this order.""" return self.lines.filter(shipped__gte=F('quantity')) - def pending_line_items(self): + def pending_line_items(self) -> QuerySet: """Return a queryset of the pending line items for this order. Note: We exclude "virtual" parts here, as they do not get allocated @@ -1595,28 +1602,28 @@ class SalesOrder(TotalPriceMixin, Order): return self.lines.filter(shipped__lt=F('quantity')).exclude(part__virtual=True) @property - def completed_line_count(self): + def completed_line_count(self) -> int: """Return the number of completed lines for this order.""" return self.completed_line_items().count() @property - def pending_line_count(self): + def pending_line_count(self) -> int: """Return the number of pending (incomplete) lines associated with this order.""" return self.pending_line_items().count() - def completed_shipments(self): + def completed_shipments(self) -> QuerySet: """Return a queryset of the completed shipments for this order.""" return self.shipments.exclude(shipment_date=None) - def pending_shipments(self): + def pending_shipments(self) -> QuerySet: """Return a queryset of the pending shipments for this order.""" return self.shipments.filter(shipment_date=None) - def allocations(self): + def allocations(self) -> QuerySet: """Return a queryset of all allocations for this order.""" return SalesOrderAllocation.objects.filter(line__order=self) - def pending_allocations(self): + def pending_allocations(self) -> QuerySet: """Return a queryset of any pending allocations for this order. Allocations are pending if: @@ -1630,22 +1637,22 @@ class SalesOrder(TotalPriceMixin, Order): return self.allocations().filter(Q1 | Q2).distinct() @property - def shipment_count(self): + def shipment_count(self) -> int: """Return the total number of shipments associated with this order.""" return self.shipments.count() @property - def completed_shipment_count(self): + def completed_shipment_count(self) -> int: """Return the number of completed shipments associated with this order.""" return self.completed_shipments().count() @property - def pending_shipment_count(self): + def pending_shipment_count(self) -> int: """Return the number of pending shipments associated with this order.""" return self.pending_shipments().count() @property - def pending_allocation_count(self): + def pending_allocation_count(self) -> int: """Return the number of pending (non-shipped) allocations.""" return self.pending_allocations().count() @@ -1824,14 +1831,17 @@ class PurchaseOrderLineItem(OrderLineItem): ) @staticmethod - def get_api_url(): + def get_api_url() -> str: """Return the API URL associated with the PurchaseOrderLineItem model.""" return reverse('api-po-line-list') - def clean(self): + def clean(self) -> None: """Custom clean method for the PurchaseOrderLineItem model. - Ensure the supplier part matches the supplier + Raises: + ValidationError: If the SupplierPart does not match the PurchaseOrder supplier + ValidationError: If the linked BuildOrder is not marked as external + ValidationError: If the linked BuildOrder part does not match the line item part """ super().clean() @@ -1963,7 +1973,7 @@ class PurchaseOrderLineItem(OrderLineItem): """Determine if this line item has been fully received.""" return self.received >= self.quantity - def update_pricing(self): + def update_pricing(self) -> None: """Update pricing information based on the supplier part data.""" if self.part: price = self.part.get_price( @@ -1992,7 +2002,7 @@ class PurchaseOrderExtraLine(OrderExtraLine): verbose_name = _('Purchase Order Extra Line') @staticmethod - def get_api_url(): + def get_api_url() -> str: """Return the API URL associated with the PurchaseOrderExtraLine model.""" return reverse('api-po-extra-line-list') @@ -2032,8 +2042,12 @@ class SalesOrderLineItem(OrderLineItem): """Return the API URL associated with the SalesOrderLineItem model.""" return reverse('api-so-line-list') - def clean(self): - """Perform extra validation steps for this SalesOrderLineItem instance.""" + def clean(self) -> None: + """Perform extra validation steps for this SalesOrderLineItem instance. + + Raises: + ValidationError: If the linked part is not salable + """ super().clean() if self.part: @@ -2108,7 +2122,7 @@ class SalesOrderLineItem(OrderLineItem): return query['allocated'] - def is_fully_allocated(self): + def is_fully_allocated(self) -> bool: """Return True if this line item is fully allocated.""" # If the linked part is "virtual", then we cannot allocate stock against it if self.part and self.part.virtual: @@ -2119,11 +2133,11 @@ class SalesOrderLineItem(OrderLineItem): return self.allocated_quantity() >= self.quantity - def is_overallocated(self): + def is_overallocated(self) -> bool: """Return True if this line item is over allocated.""" return self.allocated_quantity() > self.quantity - def is_completed(self): + def is_completed(self) -> bool: """Return True if this line item is completed (has been fully shipped).""" # A "virtual" part is always considered to be "completed" if self.part and self.part.virtual: @@ -2189,8 +2203,12 @@ class SalesOrderShipment( unique_together = ['order', 'reference'] verbose_name = _('Sales Order Shipment') - def clean(self): - """Custom clean method for the SalesOrderShipment class.""" + def clean(self) -> None: + """Custom clean method for the SalesOrderShipment class. + + Raises: + ValidationError: If the shipment address does not match the customer + """ super().clean() if self.order and self.shipment_address: @@ -2200,7 +2218,7 @@ class SalesOrderShipment( }) @staticmethod - def get_api_url(): + def get_api_url() -> str: """Return the API URL associated with the SalesOrderShipment model.""" return reverse('api-so-shipment-list') @@ -2299,16 +2317,24 @@ class SalesOrderShipment( """ return self.shipment_address or self.order.address - def is_complete(self): + def is_checked(self) -> bool: + """Return True if this shipment has been checked.""" + return self.checked_by is not None + + def is_complete(self) -> bool: """Return True if this shipment has already been completed.""" return self.shipment_date is not None - def is_delivered(self): + def is_delivered(self) -> bool: """Return True if this shipment has already been delivered.""" return self.delivery_date is not None - def check_can_complete(self, raise_error=True): - """Check if this shipment is able to be completed.""" + def check_can_complete(self, raise_error: bool = True) -> bool: + """Check if this shipment is able to be completed. + + Arguments: + raise_error: If True, raise ValidationError if cannot complete + """ try: if self.shipment_date: # Shipment has already been sent! @@ -2317,6 +2343,14 @@ class SalesOrderShipment( if self.allocations.count() == 0: raise ValidationError(_('Shipment has no allocated stock items')) + if ( + get_global_setting('SALESORDER_SHIPMENT_REQUIRES_CHECK') + and not self.is_checked() + ): + raise ValidationError( + _('Shipment must be checked before it can be completed') + ) + except ValidationError as e: if raise_error: raise e diff --git a/src/backend/InvenTree/order/test_sales_order.py b/src/backend/InvenTree/order/test_sales_order.py index e922aac5ee..c4cc9d4dc9 100644 --- a/src/backend/InvenTree/order/test_sales_order.py +++ b/src/backend/InvenTree/order/test_sales_order.py @@ -8,6 +8,7 @@ from django.core.exceptions import ValidationError import order.tasks from common.models import InvenTreeSetting, NotificationMessage +from common.settings import set_global_setting from company.models import Address, Company from InvenTree import status_codes as status from InvenTree.unit_test import InvenTreeTestCase, addUserPermission @@ -265,6 +266,25 @@ class SalesOrderTest(InvenTreeTestCase): self.assertIsNone(self.shipment.shipment_date) self.assertFalse(self.shipment.is_complete()) + # Require that the shipment is checked before completion + set_global_setting('SALESORDER_SHIPMENT_REQUIRES_CHECK', True) + + self.assertFalse(self.shipment.is_checked()) + self.assertFalse(self.shipment.check_can_complete(raise_error=False)) + + with self.assertRaises(ValidationError) as err: + self.shipment.complete_shipment(None) + + self.assertIn( + 'Shipment must be checked before it can be completed', + err.exception.messages, + ) + + # Mark the shipment as checked + self.shipment.checked_by = get_user_model().objects.first() + self.shipment.save() + self.assertTrue(self.shipment.is_checked()) + # Mark the shipments as complete self.shipment.complete_shipment(None) self.assertTrue(self.shipment.is_complete()) diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 16e7d364d0..681cd5a507 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -1042,9 +1042,7 @@ class PartSerializer( ) price_breaks = enable_filter( - PartSalePriceSerializer( - source='salepricebreaks', many=True, read_only=True, allow_null=True - ), + PartSalePriceSerializer(source='salepricebreaks', many=True, read_only=True), False, filter_name='price_breaks', ) diff --git a/src/frontend/src/functions/icons.tsx b/src/frontend/src/functions/icons.tsx index a771428a78..b50e04e4cb 100644 --- a/src/frontend/src/functions/icons.tsx +++ b/src/frontend/src/functions/icons.tsx @@ -31,6 +31,7 @@ import { IconCornerDownLeft, IconCornerDownRight, IconCornerUpRightDouble, + IconCubeSend, IconCurrencyDollar, IconDots, IconEdit, @@ -154,7 +155,7 @@ const icons: InvenTreeIconType = { sales_orders: IconTruckDelivery, scheduling: IconCalendarStats, scrap: IconCircleX, - shipment: IconTruckDelivery, + shipment: IconCubeSend, test_templates: IconTestPipe, test: IconTestPipe, related_parts: IconLayersLinked, diff --git a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx index 6bd56a9e73..918033494a 100644 --- a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx +++ b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx @@ -305,7 +305,8 @@ export default function SystemSettings() { 'SALESORDER_REQUIRE_RESPONSIBLE', 'SALESORDER_DEFAULT_SHIPMENT', 'SALESORDER_EDIT_COMPLETED_ORDERS', - 'SALESORDER_SHIP_COMPLETE' + 'SALESORDER_SHIP_COMPLETE', + 'SALESORDER_SHIPMENT_REQUIRES_CHECK' ]} /> ) diff --git a/src/frontend/src/pages/sales/SalesOrderDetail.tsx b/src/frontend/src/pages/sales/SalesOrderDetail.tsx index 44debd0c5c..bd3178fb3d 100644 --- a/src/frontend/src/pages/sales/SalesOrderDetail.tsx +++ b/src/frontend/src/pages/sales/SalesOrderDetail.tsx @@ -2,10 +2,10 @@ import { t } from '@lingui/core/macro'; import { Accordion, Grid, Skeleton, Stack, Text } from '@mantine/core'; import { IconBookmark, + IconCubeSend, IconInfoCircle, IconList, - IconTools, - IconTruckDelivery + IconTools } from '@tabler/icons-react'; import { type ReactNode, useMemo } from 'react'; import { useParams } from 'react-router-dom'; @@ -394,7 +394,7 @@ export default function SalesOrderDetail() { { name: 'shipments', label: t`Shipments`, - icon: , + icon: , content: ( user.userId(), [user]); + const { instance: shipment, instanceQuery: shipmentQuery, @@ -74,6 +82,8 @@ export default function SalesOrderShipmentDetail() { const isPending = useMemo(() => !shipment.shipment_date, [shipment]); + const isChecked = useMemo(() => !!shipment.checked_by, [shipment]); + const detailsPanel = useMemo(() => { if (shipmentQuery.isFetching || customerQuery.isFetching) { return ; @@ -180,6 +190,18 @@ export default function SalesOrderShipmentDetail() { icon: 'packages', label: t`Allocated Items` }, + { + type: 'text', + name: 'checked_by', + label: t`Checked By`, + icon: 'check', + value_formatter: () => + shipment.checked_by_detail ? ( + + ) : ( + {t`Not checked`} + ) + }, { type: 'text', name: 'shipment_date', @@ -298,6 +320,46 @@ export default function SalesOrderShipmentDetail() { onFormSuccess: refreshShipment }); + const checkShipment = useEditApiFormModal({ + url: ApiEndpoints.sales_order_shipment_list, + pk: shipment.pk, + title: t`Check Shipment`, + preFormContent: ( + } title={t`Check Shipment`}> + {t`Marking the shipment as checked indicates that you have verified that all items included in this shipment are correct`} + + ), + fetchInitialData: false, + fields: { + checked_by: { + hidden: true, + value: userId + } + }, + successMessage: t`Shipment marked as checked`, + onFormSuccess: refreshShipment + }); + + const uncheckShipment = useEditApiFormModal({ + url: ApiEndpoints.sales_order_shipment_list, + pk: shipment.pk, + title: t`Uncheck Shipment`, + preFormContent: ( + } title={t`Uncheck Shipment`}> + {t`Marking the shipment as unchecked indicates that the shipment requires further verification`} + + ), + fetchInitialData: false, + fields: { + checked_by: { + hidden: true, + value: null + } + }, + successMessage: t`Shipment marked as unchecked`, + onFormSuccess: refreshShipment + }); + const shipmentBadges = useMemo(() => { if (shipmentQuery.isFetching) { return []; @@ -310,6 +372,18 @@ export default function SalesOrderShipmentDetail() { color='gray' visible={isPending} />, + , + , ]; - }, [isPending, shipment.deliveryDate, shipmentQuery.isFetching]); + }, [isPending, isChecked, shipment.deliveryDate, shipmentQuery.isFetching]); const shipmentActions = useMemo(() => { const canEdit: boolean = user.hasChangePermission( @@ -363,6 +437,20 @@ export default function SalesOrderShipmentDetail() { onClick: editShipment.open, tooltip: t`Edit Shipment` }), + { + hidden: !isPending || isChecked, + name: t`Check`, + tooltip: t`Mark shipment as checked`, + icon: , + onClick: checkShipment.open + }, + { + hidden: !isPending || !isChecked, + name: t`Uncheck`, + tooltip: t`Mark shipment as unchecked`, + icon: , + onClick: uncheckShipment.open + }, CancelItemAction({ hidden: !isPending, onClick: deleteShipment.open, @@ -371,13 +459,15 @@ export default function SalesOrderShipmentDetail() { ]} /> ]; - }, [isPending, user, shipment]); + }, [isChecked, isPending, user, shipment]); return ( <> {completeShipment.modal} {editShipment.modal} {deleteShipment.modal} + {checkShipment.modal} + {uncheckShipment.modal} + }, { accessor: 'shipped', title: t`Shipped`, @@ -114,6 +121,13 @@ export default function SalesOrderShipmentTable({ sortable: false, render: (record: any) => }, + { + accessor: 'delivered', + title: t`Delivered`, + switchable: true, + sortable: false, + render: (record: any) => + }, DateColumn({ accessor: 'shipment_date', title: t`Shipment Date` @@ -191,6 +205,11 @@ export default function SalesOrderShipmentTable({ const tableFilters: TableFilter[] = useMemo(() => { return [ + { + name: 'checked', + label: t`Checked`, + description: t`Show shipments which have been checked` + }, { name: 'shipped', label: t`Shipped`,