2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-10-30 04:35:42 +00:00

[UI] Implement "checked_by" for SalesOrderShipment (#10654)

* Add "checked" column to SalesOrderStatus table

* Add API filter for "checked" status

* Add Checked / Not Checked badge

* Add actions to check / uncheck shipment

* Add modal for changing checked_by status

* Display checked_by user

* Tweak wording

* Bump API version

* Update CHANGELOG file

* Update docs

* Add new global setting

- Prevent shipment completion which have not been checked

* Test if shipment has been checked

* Updated unit tests

* Updated type hinting

(may as well while I'm here)

* Adjust shipment icon

* Add "order_outstanding" filter for SalesOrderShipment table
This commit is contained in:
Oliver
2025-10-24 13:39:57 +11:00
committed by GitHub
parent 435d34568b
commit 6df97e83f5
14 changed files with 295 additions and 76 deletions

View File

@@ -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) - 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) - 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 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 ### Changed

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -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. 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 ### Complete Shipment
To complete a shipment, click on the <span class="badge inventree nav side">{{ icon("truck-loading") }} Pending Shipments</span> tab then click on {{ icon("truck-delivery") }} button shown in the shipment table. To complete a shipment, click on the <span class="badge inventree nav side">{{ icon("truck-loading") }} Pending Shipments</span> 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_DEFAULT_SHIPMENT") }}
{{ globalsetting("SALESORDER_EDIT_COMPLETED_ORDERS") }} {{ globalsetting("SALESORDER_EDIT_COMPLETED_ORDERS") }}
{{ globalsetting("SALESORDER_SHIP_COMPLETE") }} {{ globalsetting("SALESORDER_SHIP_COMPLETE") }}
{{ globalsetting("SALESORDER_SHIPMENT_REQUIRES_CHECK") }}

View File

@@ -1,11 +1,16 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # 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.""" """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 = """
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 v416 -> 2025-10-22 : https://github.com/inventree/InvenTree/pull/10651
- Add missing nullable to make price_breaks (from v412) optional - Add missing nullable to make price_breaks (from v412) optional

View File

@@ -854,6 +854,14 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': False, 'default': False,
'validator': bool, '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': { 'SALESORDER_SHIP_COMPLETE': {
'name': _('Mark Shipped Orders as Complete'), 'name': _('Mark Shipped Orders as Complete'),
'description': _( 'description': _(

View File

@@ -1335,6 +1335,14 @@ class SalesOrderShipmentFilter(FilterSet):
model = models.SalesOrderShipment model = models.SalesOrderShipment
fields = ['order'] 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') shipped = rest_filters.BooleanFilter(label='shipped', method='filter_shipped')
def filter_shipped(self, queryset, name, value): def filter_shipped(self, queryset, name, value):
@@ -1351,6 +1359,27 @@ class SalesOrderShipmentFilter(FilterSet):
return queryset.exclude(delivery_date=None) return queryset.exclude(delivery_date=None)
return queryset.filter(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: class SalesOrderShipmentMixin:
"""Mixin class for SalesOrderShipment endpoints.""" """Mixin class for SalesOrderShipment endpoints."""

View File

@@ -336,7 +336,7 @@ class Order(
A locked order cannot be modified after it has been completed. 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). db: If True, check with the database. If False, check the instance (default False).
""" """
if not self.check_complete(db=db): if not self.check_complete(db=db):
@@ -351,7 +351,7 @@ class Order(
def check_complete(self, db: bool = False) -> bool: def check_complete(self, db: bool = False) -> bool:
"""Check if this order is 'complete'. """Check if this order is 'complete'.
Args: Arguments:
db: If True, check with the database. If False, check the instance (default False). 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 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 report context data for this PurchaseOrder."""
return {**super().report_context(), 'supplier': self.supplier} 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.""" """Get the 'web' URL for this order."""
return pui_url(f'/purchasing/purchase-order/{self.pk}') return pui_url(f'/purchasing/purchase-order/{self.pk}')
@staticmethod @staticmethod
def get_api_url(): def get_api_url() -> str:
"""Return the API URL associated with the PurchaseOrder model.""" """Return the API URL associated with the PurchaseOrder model."""
return reverse('api-po-list') return reverse('api-po-list')
@@ -584,7 +584,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
return defaults return defaults
@classmethod @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 the associated barcode model type code for this model."""
return 'PO' return 'PO'
@@ -805,10 +805,10 @@ class PurchaseOrder(TotalPriceMixin, Order):
@transaction.atomic @transaction.atomic
def issue_order(self): def issue_order(self):
"""Equivalent to 'place_order'.""" """Equivalent to 'place_order'."""
self.place_order() return self.place_order()
@property @property
def can_issue(self): def can_issue(self) -> bool:
"""Return True if this order can be issued.""" """Return True if this order can be issued."""
return self.status in [ return self.status in [
PurchaseOrderStatus.PENDING.value, PurchaseOrderStatus.PENDING.value,
@@ -844,17 +844,17 @@ class PurchaseOrder(TotalPriceMixin, Order):
) )
@property @property
def is_pending(self): def is_pending(self) -> bool:
"""Return True if the PurchaseOrder is 'pending'.""" """Return True if the PurchaseOrder is 'pending'."""
return self.status == PurchaseOrderStatus.PENDING.value return self.status == PurchaseOrderStatus.PENDING.value
@property @property
def is_open(self): def is_open(self) -> bool:
"""Return True if the PurchaseOrder is 'open'.""" """Return True if the PurchaseOrder is 'open'."""
return self.status in PurchaseOrderStatusGroups.OPEN return self.status in PurchaseOrderStatusGroups.OPEN
@property @property
def can_cancel(self): def can_cancel(self) -> bool:
"""A PurchaseOrder can only be cancelled under the following circumstances. """A PurchaseOrder can only be cancelled under the following circumstances.
- Status is PLACED - Status is PLACED
@@ -880,7 +880,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
) )
@property @property
def can_hold(self): def can_hold(self) -> bool:
"""Return True if this order can be placed on hold.""" """Return True if this order can be placed on hold."""
return self.status in [ return self.status in [
PurchaseOrderStatus.PENDING.value, PurchaseOrderStatus.PENDING.value,
@@ -897,34 +897,34 @@ class PurchaseOrder(TotalPriceMixin, Order):
# endregion # endregion
def pending_line_items(self): def pending_line_items(self) -> QuerySet:
"""Return a list of pending line items for this order. """Return a list of pending line items for this order.
Any line item where 'received' < 'quantity' will be returned. Any line item where 'received' < 'quantity' will be returned.
""" """
return self.lines.filter(quantity__gt=F('received')) 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 a list of completed line items against this order."""
return self.lines.filter(quantity__lte=F('received')) return self.lines.filter(quantity__lte=F('received'))
@property @property
def line_count(self): def line_count(self) -> int:
"""Return the total number of line items associated with this order.""" """Return the total number of line items associated with this order."""
return self.lines.count() return self.lines.count()
@property @property
def completed_line_count(self): def completed_line_count(self) -> int:
"""Return the number of complete line items associated with this order.""" """Return the number of complete line items associated with this order."""
return self.completed_line_items().count() return self.completed_line_items().count()
@property @property
def pending_line_count(self): def pending_line_count(self) -> int:
"""Return the number of pending line items associated with this order.""" """Return the number of pending line items associated with this order."""
return self.pending_line_items().count() return self.pending_line_items().count()
@property @property
def is_complete(self): def is_complete(self) -> bool:
"""Return True if all line items have been received.""" """Return True if all line items have been received."""
return self.pending_line_items().count() == 0 return self.pending_line_items().count() == 0
@@ -1247,12 +1247,12 @@ class SalesOrder(TotalPriceMixin, Order):
"""Generate report context data for this SalesOrder.""" """Generate report context data for this SalesOrder."""
return {**super().report_context(), 'customer': self.customer} 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.""" """Get the 'web' URL for this order."""
return pui_url(f'/sales/sales-order/{self.pk}') return pui_url(f'/sales/sales-order/{self.pk}')
@staticmethod @staticmethod
def get_api_url(): def get_api_url() -> str:
"""Return the API URL associated with the SalesOrder model.""" """Return the API URL associated with the SalesOrder model."""
return reverse('api-so-list') return reverse('api-so-list')
@@ -1262,14 +1262,14 @@ class SalesOrder(TotalPriceMixin, Order):
return SalesOrderStatusGroups return SalesOrderStatusGroups
@classmethod @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.""" """Return default values for this model when issuing an API OPTIONS request."""
defaults = {'reference': order.validators.generate_next_sales_order_reference()} defaults = {'reference': order.validators.generate_next_sales_order_reference()}
return defaults return defaults
@classmethod @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 the associated barcode model type code for this model."""
return 'SO' return 'SO'
@@ -1326,7 +1326,7 @@ class SalesOrder(TotalPriceMixin, Order):
) )
@property @property
def status_text(self): def status_text(self) -> str:
"""Return the text representation of the status field.""" """Return the text representation of the status field."""
return SalesOrderStatus.text(self.status) return SalesOrderStatus.text(self.status)
@@ -1351,38 +1351,45 @@ class SalesOrder(TotalPriceMixin, Order):
) )
@property @property
def is_pending(self): def is_pending(self) -> bool:
"""Return True if this order is 'pending'.""" """Return True if this order is 'pending'."""
return self.status == SalesOrderStatus.PENDING return self.status == SalesOrderStatus.PENDING
@property @property
def is_open(self): def is_open(self) -> bool:
"""Return True if this order is 'open' (either 'pending' or 'in_progress').""" """Return True if this order is 'open' (either 'pending' or 'in_progress')."""
return self.status in SalesOrderStatusGroups.OPEN return self.status in SalesOrderStatusGroups.OPEN
@property @property
def stock_allocations(self): def stock_allocations(self) -> QuerySet:
"""Return a queryset containing all allocations for this order.""" """Return a queryset containing all allocations for this order."""
return SalesOrderAllocation.objects.filter( return SalesOrderAllocation.objects.filter(
line__in=[line.pk for line in self.lines.all()] 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 True if all line items are fully allocated."""
return all(line.is_fully_allocated() for line in self.lines.all()) 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 true if any lines in the order are over-allocated."""
return any(line.is_overallocated() for line in self.lines.all()) 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).""" """Check if this order is "shipped" (all line items delivered)."""
return all(line.is_completed() for line in self.lines.all()) 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. """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: try:
if self.status == SalesOrderStatus.COMPLETE.value: if self.status == SalesOrderStatus.COMPLETE.value:
@@ -1424,7 +1431,7 @@ class SalesOrder(TotalPriceMixin, Order):
self.issue_order() self.issue_order()
@property @property
def can_issue(self): def can_issue(self) -> bool:
"""Return True if this order can be issued.""" """Return True if this order can be issued."""
return self.status in [ return self.status in [
SalesOrderStatus.PENDING.value, SalesOrderStatus.PENDING.value,
@@ -1450,7 +1457,7 @@ class SalesOrder(TotalPriceMixin, Order):
) )
@property @property
def can_hold(self): def can_hold(self) -> bool:
"""Return True if this order can be placed on hold.""" """Return True if this order can be placed on hold."""
return self.status in [ return self.status in [
SalesOrderStatus.PENDING.value, SalesOrderStatus.PENDING.value,
@@ -1497,7 +1504,7 @@ class SalesOrder(TotalPriceMixin, Order):
return True return True
@property @property
def can_cancel(self): def can_cancel(self) -> bool:
"""Return True if this order can be cancelled.""" """Return True if this order can be cancelled."""
return self.is_open return self.is_open
@@ -1579,15 +1586,15 @@ class SalesOrder(TotalPriceMixin, Order):
# endregion # endregion
@property @property
def line_count(self): def line_count(self) -> int:
"""Return the total number of lines associated with this order.""" """Return the total number of lines associated with this order."""
return self.lines.count() 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 a queryset of the completed line items for this order."""
return self.lines.filter(shipped__gte=F('quantity')) 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. """Return a queryset of the pending line items for this order.
Note: We exclude "virtual" parts here, as they do not get allocated 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) return self.lines.filter(shipped__lt=F('quantity')).exclude(part__virtual=True)
@property @property
def completed_line_count(self): def completed_line_count(self) -> int:
"""Return the number of completed lines for this order.""" """Return the number of completed lines for this order."""
return self.completed_line_items().count() return self.completed_line_items().count()
@property @property
def pending_line_count(self): def pending_line_count(self) -> int:
"""Return the number of pending (incomplete) lines associated with this order.""" """Return the number of pending (incomplete) lines associated with this order."""
return self.pending_line_items().count() 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 a queryset of the completed shipments for this order."""
return self.shipments.exclude(shipment_date=None) 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 a queryset of the pending shipments for this order."""
return self.shipments.filter(shipment_date=None) return self.shipments.filter(shipment_date=None)
def allocations(self): def allocations(self) -> QuerySet:
"""Return a queryset of all allocations for this order.""" """Return a queryset of all allocations for this order."""
return SalesOrderAllocation.objects.filter(line__order=self) 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. """Return a queryset of any pending allocations for this order.
Allocations are pending if: Allocations are pending if:
@@ -1630,22 +1637,22 @@ class SalesOrder(TotalPriceMixin, Order):
return self.allocations().filter(Q1 | Q2).distinct() return self.allocations().filter(Q1 | Q2).distinct()
@property @property
def shipment_count(self): def shipment_count(self) -> int:
"""Return the total number of shipments associated with this order.""" """Return the total number of shipments associated with this order."""
return self.shipments.count() return self.shipments.count()
@property @property
def completed_shipment_count(self): def completed_shipment_count(self) -> int:
"""Return the number of completed shipments associated with this order.""" """Return the number of completed shipments associated with this order."""
return self.completed_shipments().count() return self.completed_shipments().count()
@property @property
def pending_shipment_count(self): def pending_shipment_count(self) -> int:
"""Return the number of pending shipments associated with this order.""" """Return the number of pending shipments associated with this order."""
return self.pending_shipments().count() return self.pending_shipments().count()
@property @property
def pending_allocation_count(self): def pending_allocation_count(self) -> int:
"""Return the number of pending (non-shipped) allocations.""" """Return the number of pending (non-shipped) allocations."""
return self.pending_allocations().count() return self.pending_allocations().count()
@@ -1824,14 +1831,17 @@ class PurchaseOrderLineItem(OrderLineItem):
) )
@staticmethod @staticmethod
def get_api_url(): def get_api_url() -> str:
"""Return the API URL associated with the PurchaseOrderLineItem model.""" """Return the API URL associated with the PurchaseOrderLineItem model."""
return reverse('api-po-line-list') return reverse('api-po-line-list')
def clean(self): def clean(self) -> None:
"""Custom clean method for the PurchaseOrderLineItem model. """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() super().clean()
@@ -1963,7 +1973,7 @@ class PurchaseOrderLineItem(OrderLineItem):
"""Determine if this line item has been fully received.""" """Determine if this line item has been fully received."""
return self.received >= self.quantity return self.received >= self.quantity
def update_pricing(self): def update_pricing(self) -> None:
"""Update pricing information based on the supplier part data.""" """Update pricing information based on the supplier part data."""
if self.part: if self.part:
price = self.part.get_price( price = self.part.get_price(
@@ -1992,7 +2002,7 @@ class PurchaseOrderExtraLine(OrderExtraLine):
verbose_name = _('Purchase Order Extra Line') verbose_name = _('Purchase Order Extra Line')
@staticmethod @staticmethod
def get_api_url(): def get_api_url() -> str:
"""Return the API URL associated with the PurchaseOrderExtraLine model.""" """Return the API URL associated with the PurchaseOrderExtraLine model."""
return reverse('api-po-extra-line-list') return reverse('api-po-extra-line-list')
@@ -2032,8 +2042,12 @@ class SalesOrderLineItem(OrderLineItem):
"""Return the API URL associated with the SalesOrderLineItem model.""" """Return the API URL associated with the SalesOrderLineItem model."""
return reverse('api-so-line-list') return reverse('api-so-line-list')
def clean(self): def clean(self) -> None:
"""Perform extra validation steps for this SalesOrderLineItem instance.""" """Perform extra validation steps for this SalesOrderLineItem instance.
Raises:
ValidationError: If the linked part is not salable
"""
super().clean() super().clean()
if self.part: if self.part:
@@ -2108,7 +2122,7 @@ class SalesOrderLineItem(OrderLineItem):
return query['allocated'] return query['allocated']
def is_fully_allocated(self): def is_fully_allocated(self) -> bool:
"""Return True if this line item is fully allocated.""" """Return True if this line item is fully allocated."""
# If the linked part is "virtual", then we cannot allocate stock against it # If the linked part is "virtual", then we cannot allocate stock against it
if self.part and self.part.virtual: if self.part and self.part.virtual:
@@ -2119,11 +2133,11 @@ class SalesOrderLineItem(OrderLineItem):
return self.allocated_quantity() >= self.quantity 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 True if this line item is over allocated."""
return self.allocated_quantity() > self.quantity 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).""" """Return True if this line item is completed (has been fully shipped)."""
# A "virtual" part is always considered to be "completed" # A "virtual" part is always considered to be "completed"
if self.part and self.part.virtual: if self.part and self.part.virtual:
@@ -2189,8 +2203,12 @@ class SalesOrderShipment(
unique_together = ['order', 'reference'] unique_together = ['order', 'reference']
verbose_name = _('Sales Order Shipment') verbose_name = _('Sales Order Shipment')
def clean(self): def clean(self) -> None:
"""Custom clean method for the SalesOrderShipment class.""" """Custom clean method for the SalesOrderShipment class.
Raises:
ValidationError: If the shipment address does not match the customer
"""
super().clean() super().clean()
if self.order and self.shipment_address: if self.order and self.shipment_address:
@@ -2200,7 +2218,7 @@ class SalesOrderShipment(
}) })
@staticmethod @staticmethod
def get_api_url(): def get_api_url() -> str:
"""Return the API URL associated with the SalesOrderShipment model.""" """Return the API URL associated with the SalesOrderShipment model."""
return reverse('api-so-shipment-list') return reverse('api-so-shipment-list')
@@ -2299,16 +2317,24 @@ class SalesOrderShipment(
""" """
return self.shipment_address or self.order.address 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 True if this shipment has already been completed."""
return self.shipment_date is not None 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 True if this shipment has already been delivered."""
return self.delivery_date is not None return self.delivery_date is not None
def check_can_complete(self, raise_error=True): def check_can_complete(self, raise_error: bool = True) -> bool:
"""Check if this shipment is able to be completed.""" """Check if this shipment is able to be completed.
Arguments:
raise_error: If True, raise ValidationError if cannot complete
"""
try: try:
if self.shipment_date: if self.shipment_date:
# Shipment has already been sent! # Shipment has already been sent!
@@ -2317,6 +2343,14 @@ class SalesOrderShipment(
if self.allocations.count() == 0: if self.allocations.count() == 0:
raise ValidationError(_('Shipment has no allocated stock items')) 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: except ValidationError as e:
if raise_error: if raise_error:
raise e raise e

View File

@@ -8,6 +8,7 @@ from django.core.exceptions import ValidationError
import order.tasks import order.tasks
from common.models import InvenTreeSetting, NotificationMessage from common.models import InvenTreeSetting, NotificationMessage
from common.settings import set_global_setting
from company.models import Address, Company from company.models import Address, Company
from InvenTree import status_codes as status from InvenTree import status_codes as status
from InvenTree.unit_test import InvenTreeTestCase, addUserPermission from InvenTree.unit_test import InvenTreeTestCase, addUserPermission
@@ -265,6 +266,25 @@ class SalesOrderTest(InvenTreeTestCase):
self.assertIsNone(self.shipment.shipment_date) self.assertIsNone(self.shipment.shipment_date)
self.assertFalse(self.shipment.is_complete()) 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 # Mark the shipments as complete
self.shipment.complete_shipment(None) self.shipment.complete_shipment(None)
self.assertTrue(self.shipment.is_complete()) self.assertTrue(self.shipment.is_complete())

View File

@@ -1042,9 +1042,7 @@ class PartSerializer(
) )
price_breaks = enable_filter( price_breaks = enable_filter(
PartSalePriceSerializer( PartSalePriceSerializer(source='salepricebreaks', many=True, read_only=True),
source='salepricebreaks', many=True, read_only=True, allow_null=True
),
False, False,
filter_name='price_breaks', filter_name='price_breaks',
) )

View File

@@ -31,6 +31,7 @@ import {
IconCornerDownLeft, IconCornerDownLeft,
IconCornerDownRight, IconCornerDownRight,
IconCornerUpRightDouble, IconCornerUpRightDouble,
IconCubeSend,
IconCurrencyDollar, IconCurrencyDollar,
IconDots, IconDots,
IconEdit, IconEdit,
@@ -154,7 +155,7 @@ const icons: InvenTreeIconType = {
sales_orders: IconTruckDelivery, sales_orders: IconTruckDelivery,
scheduling: IconCalendarStats, scheduling: IconCalendarStats,
scrap: IconCircleX, scrap: IconCircleX,
shipment: IconTruckDelivery, shipment: IconCubeSend,
test_templates: IconTestPipe, test_templates: IconTestPipe,
test: IconTestPipe, test: IconTestPipe,
related_parts: IconLayersLinked, related_parts: IconLayersLinked,

View File

@@ -305,7 +305,8 @@ export default function SystemSettings() {
'SALESORDER_REQUIRE_RESPONSIBLE', 'SALESORDER_REQUIRE_RESPONSIBLE',
'SALESORDER_DEFAULT_SHIPMENT', 'SALESORDER_DEFAULT_SHIPMENT',
'SALESORDER_EDIT_COMPLETED_ORDERS', 'SALESORDER_EDIT_COMPLETED_ORDERS',
'SALESORDER_SHIP_COMPLETE' 'SALESORDER_SHIP_COMPLETE',
'SALESORDER_SHIPMENT_REQUIRES_CHECK'
]} ]}
/> />
) )

View File

@@ -2,10 +2,10 @@ import { t } from '@lingui/core/macro';
import { Accordion, Grid, Skeleton, Stack, Text } from '@mantine/core'; import { Accordion, Grid, Skeleton, Stack, Text } from '@mantine/core';
import { import {
IconBookmark, IconBookmark,
IconCubeSend,
IconInfoCircle, IconInfoCircle,
IconList, IconList,
IconTools, IconTools
IconTruckDelivery
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { type ReactNode, useMemo } from 'react'; import { type ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
@@ -394,7 +394,7 @@ export default function SalesOrderDetail() {
{ {
name: 'shipments', name: 'shipments',
label: t`Shipments`, label: t`Shipments`,
icon: <IconTruckDelivery />, icon: <IconCubeSend />,
content: ( content: (
<SalesOrderShipmentTable <SalesOrderShipmentTable
orderId={order.pk} orderId={order.pk}

View File

@@ -1,6 +1,11 @@
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack, Text } from '@mantine/core'; import { Alert, Grid, Skeleton, Stack, Text } from '@mantine/core';
import { IconBookmark, IconInfoCircle } from '@tabler/icons-react'; import {
IconBookmark,
IconCircleCheck,
IconCircleX,
IconInfoCircle
} from '@tabler/icons-react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
@@ -31,6 +36,7 @@ import NotesPanel from '../../components/panels/NotesPanel';
import type { PanelType } from '../../components/panels/Panel'; import type { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup'; import { PanelGroup } from '../../components/panels/PanelGroup';
import { RenderAddress } from '../../components/render/Company'; import { RenderAddress } from '../../components/render/Company';
import { RenderUser } from '../../components/render/User';
import { formatDate } from '../../defaults/formatters'; import { formatDate } from '../../defaults/formatters';
import { import {
useSalesOrderShipmentCompleteFields, useSalesOrderShipmentCompleteFields,
@@ -50,6 +56,8 @@ export default function SalesOrderShipmentDetail() {
const user = useUserState(); const user = useUserState();
const navigate = useNavigate(); const navigate = useNavigate();
const userId = useMemo(() => user.userId(), [user]);
const { const {
instance: shipment, instance: shipment,
instanceQuery: shipmentQuery, instanceQuery: shipmentQuery,
@@ -74,6 +82,8 @@ export default function SalesOrderShipmentDetail() {
const isPending = useMemo(() => !shipment.shipment_date, [shipment]); const isPending = useMemo(() => !shipment.shipment_date, [shipment]);
const isChecked = useMemo(() => !!shipment.checked_by, [shipment]);
const detailsPanel = useMemo(() => { const detailsPanel = useMemo(() => {
if (shipmentQuery.isFetching || customerQuery.isFetching) { if (shipmentQuery.isFetching || customerQuery.isFetching) {
return <Skeleton />; return <Skeleton />;
@@ -180,6 +190,18 @@ export default function SalesOrderShipmentDetail() {
icon: 'packages', icon: 'packages',
label: t`Allocated Items` label: t`Allocated Items`
}, },
{
type: 'text',
name: 'checked_by',
label: t`Checked By`,
icon: 'check',
value_formatter: () =>
shipment.checked_by_detail ? (
<RenderUser instance={shipment.checked_by_detail} />
) : (
<Text size='sm' c='red'>{t`Not checked`}</Text>
)
},
{ {
type: 'text', type: 'text',
name: 'shipment_date', name: 'shipment_date',
@@ -298,6 +320,46 @@ export default function SalesOrderShipmentDetail() {
onFormSuccess: refreshShipment onFormSuccess: refreshShipment
}); });
const checkShipment = useEditApiFormModal({
url: ApiEndpoints.sales_order_shipment_list,
pk: shipment.pk,
title: t`Check Shipment`,
preFormContent: (
<Alert color='green' icon={<IconCircleCheck />} title={t`Check Shipment`}>
<Text>{t`Marking the shipment as checked indicates that you have verified that all items included in this shipment are correct`}</Text>
</Alert>
),
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: (
<Alert color='red' icon={<IconCircleX />} title={t`Uncheck Shipment`}>
<Text>{t`Marking the shipment as unchecked indicates that the shipment requires further verification`}</Text>
</Alert>
),
fetchInitialData: false,
fields: {
checked_by: {
hidden: true,
value: null
}
},
successMessage: t`Shipment marked as unchecked`,
onFormSuccess: refreshShipment
});
const shipmentBadges = useMemo(() => { const shipmentBadges = useMemo(() => {
if (shipmentQuery.isFetching) { if (shipmentQuery.isFetching) {
return []; return [];
@@ -310,6 +372,18 @@ export default function SalesOrderShipmentDetail() {
color='gray' color='gray'
visible={isPending} visible={isPending}
/>, />,
<DetailsBadge
key='checked'
label={t`Checked`}
color='green'
visible={isPending && isChecked}
/>,
<DetailsBadge
key='not-checked'
label={t`Not Checked`}
color='red'
visible={isPending && !isChecked}
/>,
<DetailsBadge <DetailsBadge
key='shipped' key='shipped'
label={t`Shipped`} label={t`Shipped`}
@@ -323,7 +397,7 @@ export default function SalesOrderShipmentDetail() {
visible={!!shipment.delivery_date} visible={!!shipment.delivery_date}
/> />
]; ];
}, [isPending, shipment.deliveryDate, shipmentQuery.isFetching]); }, [isPending, isChecked, shipment.deliveryDate, shipmentQuery.isFetching]);
const shipmentActions = useMemo(() => { const shipmentActions = useMemo(() => {
const canEdit: boolean = user.hasChangePermission( const canEdit: boolean = user.hasChangePermission(
@@ -363,6 +437,20 @@ export default function SalesOrderShipmentDetail() {
onClick: editShipment.open, onClick: editShipment.open,
tooltip: t`Edit Shipment` tooltip: t`Edit Shipment`
}), }),
{
hidden: !isPending || isChecked,
name: t`Check`,
tooltip: t`Mark shipment as checked`,
icon: <IconCircleCheck color='green' />,
onClick: checkShipment.open
},
{
hidden: !isPending || !isChecked,
name: t`Uncheck`,
tooltip: t`Mark shipment as unchecked`,
icon: <IconCircleX color='red' />,
onClick: uncheckShipment.open
},
CancelItemAction({ CancelItemAction({
hidden: !isPending, hidden: !isPending,
onClick: deleteShipment.open, onClick: deleteShipment.open,
@@ -371,13 +459,15 @@ export default function SalesOrderShipmentDetail() {
]} ]}
/> />
]; ];
}, [isPending, user, shipment]); }, [isChecked, isPending, user, shipment]);
return ( return (
<> <>
{completeShipment.modal} {completeShipment.modal}
{editShipment.modal} {editShipment.modal}
{deleteShipment.modal} {deleteShipment.modal}
{checkShipment.modal}
{uncheckShipment.modal}
<InstanceDetail <InstanceDetail
query={shipmentQuery} query={shipmentQuery}
requiredRole={UserRoles.sales_order} requiredRole={UserRoles.sales_order}

View File

@@ -107,6 +107,13 @@ export default function SalesOrderShipmentTable({
switchable: false, switchable: false,
title: t`Items` title: t`Items`
}, },
{
accessor: 'checked',
title: t`Checked`,
switchable: true,
sortable: false,
render: (record: any) => <YesNoButton value={!!record.checked_by} />
},
{ {
accessor: 'shipped', accessor: 'shipped',
title: t`Shipped`, title: t`Shipped`,
@@ -114,6 +121,13 @@ export default function SalesOrderShipmentTable({
sortable: false, sortable: false,
render: (record: any) => <YesNoButton value={!!record.shipment_date} /> render: (record: any) => <YesNoButton value={!!record.shipment_date} />
}, },
{
accessor: 'delivered',
title: t`Delivered`,
switchable: true,
sortable: false,
render: (record: any) => <YesNoButton value={!!record.delivery_date} />
},
DateColumn({ DateColumn({
accessor: 'shipment_date', accessor: 'shipment_date',
title: t`Shipment Date` title: t`Shipment Date`
@@ -191,6 +205,11 @@ export default function SalesOrderShipmentTable({
const tableFilters: TableFilter[] = useMemo(() => { const tableFilters: TableFilter[] = useMemo(() => {
return [ return [
{
name: 'checked',
label: t`Checked`,
description: t`Show shipments which have been checked`
},
{ {
name: 'shipped', name: 'shipped',
label: t`Shipped`, label: t`Shipped`,