mirror of
https://github.com/inventree/InvenTree.git
synced 2025-10-29 20:30:39 +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:
@@ -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
|
||||
|
||||
|
||||
BIN
docs/docs/assets/images/order/so_shipment_check.png
Normal file
BIN
docs/docs/assets/images/order/so_shipment_check.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -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 <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_EDIT_COMPLETED_ORDERS") }}
|
||||
{{ globalsetting("SALESORDER_SHIP_COMPLETE") }}
|
||||
{{ globalsetting("SALESORDER_SHIPMENT_REQUIRES_CHECK") }}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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': _(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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: <IconTruckDelivery />,
|
||||
icon: <IconCubeSend />,
|
||||
content: (
|
||||
<SalesOrderShipmentTable
|
||||
orderId={order.pk}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Grid, Skeleton, Stack, Text } from '@mantine/core';
|
||||
import { IconBookmark, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { Alert, Grid, Skeleton, Stack, Text } from '@mantine/core';
|
||||
import {
|
||||
IconBookmark,
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconInfoCircle
|
||||
} from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
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 { PanelGroup } from '../../components/panels/PanelGroup';
|
||||
import { RenderAddress } from '../../components/render/Company';
|
||||
import { RenderUser } from '../../components/render/User';
|
||||
import { formatDate } from '../../defaults/formatters';
|
||||
import {
|
||||
useSalesOrderShipmentCompleteFields,
|
||||
@@ -50,6 +56,8 @@ export default function SalesOrderShipmentDetail() {
|
||||
const user = useUserState();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const userId = useMemo(() => 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 <Skeleton />;
|
||||
@@ -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 ? (
|
||||
<RenderUser instance={shipment.checked_by_detail} />
|
||||
) : (
|
||||
<Text size='sm' c='red'>{t`Not checked`}</Text>
|
||||
)
|
||||
},
|
||||
{
|
||||
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: (
|
||||
<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(() => {
|
||||
if (shipmentQuery.isFetching) {
|
||||
return [];
|
||||
@@ -310,6 +372,18 @@ export default function SalesOrderShipmentDetail() {
|
||||
color='gray'
|
||||
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
|
||||
key='shipped'
|
||||
label={t`Shipped`}
|
||||
@@ -323,7 +397,7 @@ export default function SalesOrderShipmentDetail() {
|
||||
visible={!!shipment.delivery_date}
|
||||
/>
|
||||
];
|
||||
}, [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: <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({
|
||||
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}
|
||||
<InstanceDetail
|
||||
query={shipmentQuery}
|
||||
requiredRole={UserRoles.sales_order}
|
||||
|
||||
@@ -107,6 +107,13 @@ export default function SalesOrderShipmentTable({
|
||||
switchable: false,
|
||||
title: t`Items`
|
||||
},
|
||||
{
|
||||
accessor: 'checked',
|
||||
title: t`Checked`,
|
||||
switchable: true,
|
||||
sortable: false,
|
||||
render: (record: any) => <YesNoButton value={!!record.checked_by} />
|
||||
},
|
||||
{
|
||||
accessor: 'shipped',
|
||||
title: t`Shipped`,
|
||||
@@ -114,6 +121,13 @@ export default function SalesOrderShipmentTable({
|
||||
sortable: false,
|
||||
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({
|
||||
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`,
|
||||
|
||||
Reference in New Issue
Block a user