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`,