diff --git a/.github/scripts/version_check.py b/.github/scripts/version_check.py
index 1d05c50e87..9c59353189 100644
--- a/.github/scripts/version_check.py
+++ b/.github/scripts/version_check.py
@@ -22,7 +22,7 @@ REPO = os.getenv('GITHUB_REPOSITORY', 'inventree/inventree')
GITHUB_API_URL = os.getenv('GITHUB_API_URL', 'https://api.github.com')
-def get_existing_release_tags():
+def get_existing_release_tags(include_prerelease=True):
"""Request information on existing releases via the GitHub API."""
# Check for github token
token = os.getenv('GITHUB_TOKEN', None)
@@ -51,6 +51,9 @@ def get_existing_release_tags():
print(f"Version '{tag}' did not match expected pattern")
continue
+ if not include_prerelease and release['prerelease']:
+ continue
+
tags.append([int(x) for x in match.groups()])
return tags
@@ -74,7 +77,7 @@ def check_version_number(version_string, allow_duplicate=False):
version_tuple = [int(x) for x in match.groups()]
# Look through the existing releases
- existing = get_existing_release_tags()
+ existing = get_existing_release_tags(include_prerelease=False)
# Assume that this is the highest release, unless told otherwise
highest_release = True
diff --git a/docs/docs/build/build.md b/docs/docs/build/build.md
index f35fdcaa51..6c2f4c31df 100644
--- a/docs/docs/build/build.md
+++ b/docs/docs/build/build.md
@@ -66,10 +66,11 @@ Each *Build Order* has an associated *Status* flag, which indicates the state of
| Status | Description |
| ----------- | ----------- |
-| `Pending` | Build has been created and build is ready for subpart allocation |
-| `Production` | One or more build outputs have been created for this build |
-| `Cancelled` | Build has been cancelled |
-| `Completed` | Build has been completed |
+| `Pending` | Build order has been created, but is not yet in production |
+| `Production` | Build order is currently in production |
+| `On Hold` | Build order has been placed on hold, but is still active |
+| `Cancelled` | Build order has been cancelled |
+| `Completed` | Build order has been completed |
**Source Code**
diff --git a/docs/docs/order/purchase_order.md b/docs/docs/order/purchase_order.md
index 066f64d058..fe39c2ab8f 100644
--- a/docs/docs/order/purchase_order.md
+++ b/docs/docs/order/purchase_order.md
@@ -20,6 +20,7 @@ Each Purchase Order has a specific status code which indicates the current state
| --- | --- |
| Pending | The purchase order has been created, but has not been submitted to the supplier |
| In Progress | The purchase order has been issued to the supplier, and is in progress |
+| On Hold | The purchase order has been placed on hold, but is still active |
| Complete | The purchase order has been completed, and is now closed |
| Cancelled | The purchase order was cancelled, and is now closed |
| Lost | The purchase order was lost, and is now closed |
diff --git a/docs/docs/order/return_order.md b/docs/docs/order/return_order.md
index 4b134601ae..ad219c099b 100644
--- a/docs/docs/order/return_order.md
+++ b/docs/docs/order/return_order.md
@@ -45,6 +45,7 @@ Each Return Order has a specific status code, as follows:
| --- | --- |
| Pending | The return order has been created, but not sent to the customer |
| In Progress | The return order has been issued to the customer |
+| On Hold | The return order has been placed on hold, but is still active |
| Complete | The return order was marked as complete, and is now closed |
| Cancelled | The return order was cancelled, and is now closed |
diff --git a/docs/docs/order/sales_order.md b/docs/docs/order/sales_order.md
index 43ab9cc533..3d6f77dec0 100644
--- a/docs/docs/order/sales_order.md
+++ b/docs/docs/order/sales_order.md
@@ -20,6 +20,7 @@ Each Sales Order has a specific status code, which represents the state of the o
| --- | --- |
| Pending | The sales order has been created, but has not been finalized or submitted |
| In Progress | The sales order has been issued, and is in progress |
+| On Hold | The sales order has been placed on hold, but is still active |
| Shipped | The sales order has been shipped, but is not yet complete |
| Complete | The sales order is fully completed, and is now closed |
| Cancelled | The sales order was cancelled, and is now closed |
diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py
index 5aa93531ad..d8c92d5f25 100644
--- a/src/backend/InvenTree/InvenTree/api_version.py
+++ b/src/backend/InvenTree/InvenTree/api_version.py
@@ -1,13 +1,19 @@
"""InvenTree API version information."""
# InvenTree API version
-INVENTREE_API_VERSION = 232
+INVENTREE_API_VERSION = 233
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
+v233 - 2024-08-04 : https://github.com/inventree/InvenTree/pull/7807
+ - Adds new endpoints for managing state of build orders
+ - Adds new endpoints for managing state of purchase orders
+ - Adds new endpoints for managing state of sales orders
+ - Adds new endpoints for managing state of return orders
+
v232 - 2024-08-03 : https://github.com/inventree/InvenTree/pull/7793
- Allow ordering of SalesOrderShipment API by 'shipment_date' and 'delivery_date'
diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py
index 167b447c2c..e84ffe4396 100644
--- a/src/backend/InvenTree/build/api.py
+++ b/src/backend/InvenTree/build/api.py
@@ -470,9 +470,19 @@ class BuildFinish(BuildOrderContextMixin, CreateAPI):
"""API endpoint for marking a build as finished (completed)."""
queryset = Build.objects.none()
-
serializer_class = build.serializers.BuildCompleteSerializer
+ def get_queryset(self):
+ """Return the queryset for the BuildFinish API endpoint."""
+
+ queryset = super().get_queryset()
+ queryset = queryset.prefetch_related(
+ 'build_lines',
+ 'build_lines__allocations'
+ )
+
+ return queryset
+
class BuildAutoAllocate(BuildOrderContextMixin, CreateAPI):
"""API endpoint for 'automatically' allocating stock against a build order.
@@ -484,7 +494,6 @@ class BuildAutoAllocate(BuildOrderContextMixin, CreateAPI):
"""
queryset = Build.objects.none()
-
serializer_class = build.serializers.BuildAutoAllocationSerializer
@@ -500,10 +509,22 @@ class BuildAllocate(BuildOrderContextMixin, CreateAPI):
"""
queryset = Build.objects.none()
-
serializer_class = build.serializers.BuildAllocationSerializer
+class BuildIssue(BuildOrderContextMixin, CreateAPI):
+ """API endpoint for issuing a BuildOrder."""
+
+ queryset = Build.objects.all()
+ serializer_class = build.serializers.BuildIssueSerializer
+
+
+class BuildHold(BuildOrderContextMixin, CreateAPI):
+ """API endpoint for placing a BuildOrder on hold."""
+
+ queryset = Build.objects.all()
+ serializer_class = build.serializers.BuildHoldSerializer
+
class BuildCancel(BuildOrderContextMixin, CreateAPI):
"""API endpoint for cancelling a BuildOrder."""
@@ -663,6 +684,8 @@ build_api_urls = [
path('create-output/', BuildOutputCreate.as_view(), name='api-build-output-create'),
path('delete-outputs/', BuildOutputDelete.as_view(), name='api-build-output-delete'),
path('scrap-outputs/', BuildOutputScrap.as_view(), name='api-build-output-scrap'),
+ path('issue/', BuildIssue.as_view(), name='api-build-issue'),
+ path('hold/', BuildHold.as_view(), name='api-build-hold'),
path('finish/', BuildFinish.as_view(), name='api-build-finish'),
path('cancel/', BuildCancel.as_view(), name='api-build-cancel'),
path('unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),
diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py
index 85a97fb625..eb0449ea94 100644
--- a/src/backend/InvenTree/build/models.py
+++ b/src/backend/InvenTree/build/models.py
@@ -2,7 +2,6 @@
import decimal
import logging
-import os
from datetime import datetime
from django.conf import settings
@@ -26,6 +25,7 @@ from build.status_codes import BuildStatus, BuildStatusGroups
from stock.status_codes import StockStatus, StockHistoryCode
from build.validators import generate_next_build_reference, validate_build_order_reference
+from generic.states import StateTransitionMixin
import InvenTree.fields
import InvenTree.helpers
@@ -56,6 +56,7 @@ class Build(
InvenTree.models.MetadataMixin,
InvenTree.models.PluginValidationMixin,
InvenTree.models.ReferenceIndexingMixin,
+ StateTransitionMixin,
MPTTModel):
"""A Build object organises the creation of new StockItem objects from other existing StockItem objects.
@@ -574,6 +575,10 @@ class Build(
- Completed count must meet the required quantity
- Untracked parts must be allocated
"""
+
+ if self.status != BuildStatus.PRODUCTION.value:
+ return False
+
if self.incomplete_count > 0:
return False
@@ -602,8 +607,18 @@ class Build(
def complete_build(self, user, trim_allocated_stock=False):
"""Mark this build as complete."""
+ return self.handle_transition(
+ self.status, BuildStatus.COMPLETE.value, self, self._action_complete, user=user, trim_allocated_stock=trim_allocated_stock
+ )
+
+ def _action_complete(self, *args, **kwargs):
+ """Action to be taken when a build is completed."""
+
import build.tasks
+ trim_allocated_stock = kwargs.pop('trim_allocated_stock', False)
+ user = kwargs.pop('user', None)
+
if self.incomplete_count > 0:
return
@@ -665,6 +680,59 @@ class Build(
target_exclude=[user],
)
+ @transaction.atomic
+ def issue_build(self):
+ """Mark the Build as IN PRODUCTION.
+
+ Args:
+ user: The user who is issuing the build
+ """
+ return self.handle_transition(
+ self.status, BuildStatus.PENDING.value, self, self._action_issue
+ )
+
+ @property
+ def can_issue(self):
+ """Returns True if this BuildOrder can be issued."""
+ return self.status in [
+ BuildStatus.PENDING.value,
+ BuildStatus.ON_HOLD.value,
+ ]
+
+ def _action_issue(self, *args, **kwargs):
+ """Perform the action to mark this order as PRODUCTION."""
+
+ if self.can_issue:
+ self.status = BuildStatus.PRODUCTION.value
+ self.save()
+
+ trigger_event('build.issued', id=self.pk)
+
+ @transaction.atomic
+ def hold_build(self):
+ """Mark the Build as ON HOLD."""
+
+ return self.handle_transition(
+ self.status, BuildStatus.ON_HOLD.value, self, self._action_hold
+ )
+
+ @property
+ def can_hold(self):
+ """Returns True if this BuildOrder can be placed on hold"""
+ return self.status in [
+ BuildStatus.PENDING.value,
+ BuildStatus.PRODUCTION.value,
+ ]
+
+ def _action_hold(self, *args, **kwargs):
+ """Action to be taken when a build is placed on hold."""
+
+ if self.can_hold:
+ self.status = BuildStatus.ON_HOLD.value
+ self.save()
+
+ trigger_event('build.hold', id=self.pk)
+
@transaction.atomic
def cancel_build(self, user, **kwargs):
"""Mark the Build as CANCELLED.
@@ -674,8 +742,17 @@ class Build(
- Save the Build object
"""
+ return self.handle_transition(
+ self.status, BuildStatus.CANCELLED.value, self, self._action_cancel, user=user, **kwargs
+ )
+
+ def _action_cancel(self, *args, **kwargs):
+ """Action to be taken when a build is cancelled."""
+
import build.tasks
+ user = kwargs.pop('user', None)
+
remove_allocated_stock = kwargs.get('remove_allocated_stock', False)
remove_incomplete_outputs = kwargs.get('remove_incomplete_outputs', False)
@@ -1276,7 +1353,7 @@ class Build(
@property
def is_complete(self):
"""Returns True if the build status is COMPLETE."""
- return self.status == BuildStatus.COMPLETE
+ return self.status == BuildStatus.COMPLETE.value
@transaction.atomic
def create_build_line_items(self, prevent_duplicates=True):
diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py
index e961d28144..38f6b43adf 100644
--- a/src/backend/InvenTree/build/serializers.py
+++ b/src/backend/InvenTree/build/serializers.py
@@ -34,6 +34,7 @@ import part.serializers as part_serializers
from users.serializers import OwnerSerializer
from .models import Build, BuildLine, BuildItem
+from .status_codes import BuildStatus
class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTreeModelSerializer):
@@ -597,6 +598,33 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
)
+class BuildIssueSerializer(serializers.Serializer):
+ """DRF serializer for issuing a build order."""
+
+ class Meta:
+ """Serializer metaclass"""
+ fields = []
+
+ def save(self):
+ """Issue the specified build order"""
+ build = self.context['build']
+ build.issue_build()
+
+
+class BuildHoldSerializer(serializers.Serializer):
+ """DRF serializer for placing a BuildOrder on hold."""
+
+ class Meta:
+ """Serializer metaclass."""
+ fields = []
+
+ def save(self):
+ """Place the specified build on hold."""
+ build = self.context['build']
+
+ build.hold_build()
+
+
class BuildCancelSerializer(serializers.Serializer):
"""DRF serializer class for cancelling an active BuildOrder"""
@@ -737,6 +765,9 @@ class BuildCompleteSerializer(serializers.Serializer):
"""Perform validation of this serializer prior to saving"""
build = self.context['build']
+ if build.status != BuildStatus.PRODUCTION.value:
+ raise ValidationError(_("Build order must be in production state"))
+
if build.incomplete_count > 0:
raise ValidationError(_("Build order has incomplete outputs"))
diff --git a/src/backend/InvenTree/build/status_codes.py b/src/backend/InvenTree/build/status_codes.py
index eb3e9c8901..56c8a3a5d6 100644
--- a/src/backend/InvenTree/build/status_codes.py
+++ b/src/backend/InvenTree/build/status_codes.py
@@ -10,6 +10,7 @@ class BuildStatus(StatusCode):
PENDING = 10, _('Pending'), 'secondary' # Build is pending / active
PRODUCTION = 20, _('Production'), 'primary' # Build is in production
+ ON_HOLD = 25, _('On Hold'), 'warning' # Build is on hold
CANCELLED = 30, _('Cancelled'), 'danger' # Build was cancelled
COMPLETE = 40, _('Complete'), 'success' # Build is complete
@@ -19,5 +20,6 @@ class BuildStatusGroups:
ACTIVE_CODES = [
BuildStatus.PENDING.value,
+ BuildStatus.ON_HOLD.value,
BuildStatus.PRODUCTION.value,
]
diff --git a/src/backend/InvenTree/build/templates/build/build_base.html b/src/backend/InvenTree/build/templates/build/build_base.html
index 3223c7b3d8..536b95c6ec 100644
--- a/src/backend/InvenTree/build/templates/build/build_base.html
+++ b/src/backend/InvenTree/build/templates/build/build_base.html
@@ -69,22 +69,30 @@ src="{% static 'img/blank_image.png' %}"
-{% if build.active %}
+{% if build.can_issue %}
+
+ {% trans "Issue Build" %}
+
+{% elif build.active %}
{% trans "Complete Build" %}
{% endif %}
+
{% endif %}
{% endblock actions %}
@@ -244,6 +252,31 @@ src="{% static 'img/blank_image.png' %}"
);
});
+ $('#build-hold').click(function() {
+ holdOrder(
+ '{% url "api-build-hold" build.pk %}',
+ {
+ reload: true,
+ }
+ );
+ });
+
+ $('#build-issue').click(function() {
+ constructForm('{% url "api-build-issue" build.pk %}', {
+ method: 'POST',
+ title: '{% trans "Issue Build Order" %}',
+ confirm: true,
+ preFormContent: `
+
+ {% trans "Issue this Build Order?" %}
+
+ `,
+ onSuccess: function(response) {
+ window.location.reload();
+ }
+ });
+ });
+
$("#build-complete").on('click', function() {
completeBuildOrder({{ build.pk }});
});
diff --git a/src/backend/InvenTree/build/test_build.py b/src/backend/InvenTree/build/test_build.py
index 4f06038888..7e190e3e21 100644
--- a/src/backend/InvenTree/build/test_build.py
+++ b/src/backend/InvenTree/build/test_build.py
@@ -15,6 +15,7 @@ import common.models
from common.settings import set_global_setting
import build.tasks
from build.models import Build, BuildItem, BuildLine, generate_next_build_reference
+from build.status_codes import BuildStatus
from part.models import Part, BomItem, BomItemSubstitute, PartTestTemplate
from stock.models import StockItem, StockItemTestResult
from users.models import Owner
@@ -175,6 +176,7 @@ class BuildTestBase(TestCase):
part=cls.assembly,
quantity=10,
issued_by=get_user_model().objects.get(pk=1),
+ status=BuildStatus.PENDING,
)
# Create some BuildLine items we can use later on
@@ -321,6 +323,10 @@ class BuildTest(BuildTestBase):
# Build is PENDING
self.assertEqual(self.build.status, status.BuildStatus.PENDING)
+ self.assertTrue(self.build.is_active)
+ self.assertTrue(self.build.can_hold)
+ self.assertTrue(self.build.can_issue)
+
# Build has two build outputs
self.assertEqual(self.build.output_count, 2)
@@ -470,6 +476,11 @@ class BuildTest(BuildTestBase):
def test_overallocation_and_trim(self):
"""Test overallocation of stock and trim function"""
+
+ self.assertEqual(self.build.status, status.BuildStatus.PENDING)
+ self.build.issue_build()
+ self.assertEqual(self.build.status, status.BuildStatus.PRODUCTION)
+
# Fully allocate tracked stock (not eligible for trimming)
self.allocate_stock(
self.output_1,
@@ -516,6 +527,7 @@ class BuildTest(BuildTestBase):
self.build.complete_build_output(self.output_1, None)
self.build.complete_build_output(self.output_2, None)
+
self.assertTrue(self.build.can_complete)
n = StockItem.objects.filter(consumed_by=self.build).count()
@@ -583,6 +595,8 @@ class BuildTest(BuildTestBase):
self.stock_2_1.quantity = 30
self.stock_2_1.save()
+ self.build.issue_build()
+
# Allocate non-tracked parts
self.allocate_stock(
None,
diff --git a/src/backend/InvenTree/generic/states/states.py b/src/backend/InvenTree/generic/states/states.py
index 372a5bec45..c72b201eca 100644
--- a/src/backend/InvenTree/generic/states/states.py
+++ b/src/backend/InvenTree/generic/states/states.py
@@ -16,11 +16,26 @@ class BaseEnum(enum.IntEnum):
obj._value_ = args[0]
return obj
+ def __int__(self):
+ """Return an integer representation of the value."""
+ return self.value
+
+ def __str__(self):
+ """Return a string representation of the value."""
+ return str(self.value)
+
def __eq__(self, obj):
"""Override equality operator to allow comparison with int."""
- if type(self) is type(obj):
- return super().__eq__(obj)
- return self.value == obj
+ if type(obj) is int:
+ return self.value == obj
+
+ if isinstance(obj, BaseEnum):
+ return self.value == obj.value
+
+ if hasattr(obj, 'value'):
+ return self.value == obj.value
+
+ return super().__eq__(obj)
def __ne__(self, obj):
"""Override inequality operator to allow comparison with int."""
diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py
index 92603fadfb..d5618d26d7 100644
--- a/src/backend/InvenTree/order/api.py
+++ b/src/backend/InvenTree/order/api.py
@@ -360,6 +360,12 @@ class PurchaseOrderContextMixin:
return context
+class PurchaseOrderHold(PurchaseOrderContextMixin, CreateAPI):
+ """API endpoint to place a PurchaseOrder on hold."""
+
+ serializer_class = serializers.PurchaseOrderHoldSerializer
+
+
class PurchaseOrderCancel(PurchaseOrderContextMixin, CreateAPI):
"""API endpoint to 'cancel' a purchase order.
@@ -893,6 +899,12 @@ class SalesOrderContextMixin:
return ctx
+class SalesOrderHold(SalesOrderContextMixin, CreateAPI):
+ """API endpoint to place a SalesOrder on hold."""
+
+ serializer_class = serializers.SalesOrderHoldSerializer
+
+
class SalesOrderCancel(SalesOrderContextMixin, CreateAPI):
"""API endpoint to cancel a SalesOrder."""
@@ -1198,6 +1210,12 @@ class ReturnOrderCancel(ReturnOrderContextMixin, CreateAPI):
serializer_class = serializers.ReturnOrderCancelSerializer
+class ReturnOrderHold(ReturnOrderContextMixin, CreateAPI):
+ """API endpoint to hold a ReturnOrder."""
+
+ serializer_class = serializers.ReturnOrderHoldSerializer
+
+
class ReturnOrderComplete(ReturnOrderContextMixin, CreateAPI):
"""API endpoint to complete a ReturnOrder."""
@@ -1481,6 +1499,7 @@ order_api_urls = [
path(
'cancel/', PurchaseOrderCancel.as_view(), name='api-po-cancel'
),
+ path('hold/', PurchaseOrderHold.as_view(), name='api-po-hold'),
path(
'complete/',
PurchaseOrderComplete.as_view(),
@@ -1610,6 +1629,7 @@ order_api_urls = [
SalesOrderAllocateSerials.as_view(),
name='api-so-allocate-serials',
),
+ path('hold/', SalesOrderHold.as_view(), name='api-so-hold'),
path('cancel/', SalesOrderCancel.as_view(), name='api-so-cancel'),
path('issue/', SalesOrderIssue.as_view(), name='api-so-issue'),
path(
@@ -1709,6 +1729,7 @@ order_api_urls = [
ReturnOrderCancel.as_view(),
name='api-return-order-cancel',
),
+ path('hold/', ReturnOrderHold.as_view(), name='api-ro-hold'),
path(
'complete/',
ReturnOrderComplete.as_view(),
diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py
index 2362c01ee4..3d2039a84c 100644
--- a/src/backend/InvenTree/order/models.py
+++ b/src/backend/InvenTree/order/models.py
@@ -609,7 +609,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
Order must be currently PENDING.
"""
- if self.is_pending:
+ if self.can_issue:
self.status = PurchaseOrderStatus.PLACED.value
self.issue_date = InvenTree.helpers.current_date()
self.save()
@@ -642,6 +642,19 @@ class PurchaseOrder(TotalPriceMixin, Order):
trigger_event('purchaseorder.completed', id=self.pk)
+ @transaction.atomic
+ def issue_order(self):
+ """Equivalent to 'place_order'."""
+ self.place_order()
+
+ @property
+ def can_issue(self):
+ """Return True if this order can be issued."""
+ return self.status in [
+ PurchaseOrderStatus.PENDING.value,
+ PurchaseOrderStatus.ON_HOLD.value,
+ ]
+
@transaction.atomic
def place_order(self):
"""Attempt to transition to PLACED status."""
@@ -656,6 +669,13 @@ class PurchaseOrder(TotalPriceMixin, Order):
self.status, PurchaseOrderStatus.COMPLETE.value, self, self._action_complete
)
+ @transaction.atomic
+ def hold_order(self):
+ """Attempt to transition to ON_HOLD status."""
+ return self.handle_transition(
+ self.status, PurchaseOrderStatus.ON_HOLD.value, self, self._action_hold
+ )
+
@transaction.atomic
def cancel_order(self):
"""Attempt to transition to CANCELLED status."""
@@ -678,12 +698,9 @@ class PurchaseOrder(TotalPriceMixin, Order):
"""A PurchaseOrder can only be cancelled under the following circumstances.
- Status is PLACED
- - Status is PENDING
+ - Status is PENDING (or ON_HOLD)
"""
- return self.status in [
- PurchaseOrderStatus.PLACED.value,
- PurchaseOrderStatus.PENDING.value,
- ]
+ return self.status in PurchaseOrderStatusGroups.OPEN
def _action_cancel(self, *args, **kwargs):
"""Marks the PurchaseOrder as CANCELLED."""
@@ -701,6 +718,22 @@ class PurchaseOrder(TotalPriceMixin, Order):
content=InvenTreeNotificationBodies.OrderCanceled,
)
+ @property
+ def can_hold(self):
+ """Return True if this order can be placed on hold."""
+ return self.status in [
+ PurchaseOrderStatus.PENDING.value,
+ PurchaseOrderStatus.PLACED.value,
+ ]
+
+ def _action_hold(self, *args, **kwargs):
+ """Mark this purchase order as 'on hold'."""
+ if self.can_hold:
+ self.status = PurchaseOrderStatus.ON_HOLD.value
+ self.save()
+
+ trigger_event('purchaseorder.hold', id=self.pk)
+
# endregion
def pending_line_items(self):
@@ -1074,15 +1107,39 @@ class SalesOrder(TotalPriceMixin, Order):
"""Deprecated version of 'issue_order'."""
self.issue_order()
+ @property
+ def can_issue(self):
+ """Return True if this order can be issued."""
+ return self.status in [
+ SalesOrderStatus.PENDING.value,
+ SalesOrderStatus.ON_HOLD.value,
+ ]
+
def _action_place(self, *args, **kwargs):
"""Change this order from 'PENDING' to 'IN_PROGRESS'."""
- if self.status == SalesOrderStatus.PENDING:
+ if self.can_issue:
self.status = SalesOrderStatus.IN_PROGRESS.value
self.issue_date = InvenTree.helpers.current_date()
self.save()
trigger_event('salesorder.issued', id=self.pk)
+ @property
+ def can_hold(self):
+ """Return True if this order can be placed on hold."""
+ return self.status in [
+ SalesOrderStatus.PENDING.value,
+ SalesOrderStatus.IN_PROGRESS.value,
+ ]
+
+ def _action_hold(self, *args, **kwargs):
+ """Mark this sales order as 'on hold'."""
+ if self.can_hold:
+ self.status = SalesOrderStatus.ON_HOLD.value
+ self.save()
+
+ trigger_event('salesorder.onhold', id=self.pk)
+
def _action_complete(self, *args, **kwargs):
"""Mark this order as "complete."""
user = kwargs.pop('user', None)
@@ -1176,6 +1233,13 @@ class SalesOrder(TotalPriceMixin, Order):
**kwargs,
)
+ @transaction.atomic
+ def hold_order(self):
+ """Attempt to transition to ON_HOLD status."""
+ return self.handle_transition(
+ self.status, SalesOrderStatus.ON_HOLD.value, self, self._action_hold
+ )
+
@transaction.atomic
def cancel_order(self):
"""Attempt to transition to CANCELLED status."""
@@ -2133,9 +2197,30 @@ class ReturnOrder(TotalPriceMixin, Order):
"""Return True if this order is fully received."""
return not self.lines.filter(received_date=None).exists()
+ @property
+ def can_hold(self):
+ """Return True if this order can be placed on hold."""
+ return self.status in [
+ ReturnOrderStatus.PENDING.value,
+ ReturnOrderStatus.IN_PROGRESS.value,
+ ]
+
+ def _action_hold(self, *args, **kwargs):
+ """Mark this order as 'on hold' (if allowed)."""
+ if self.can_hold:
+ self.status = ReturnOrderStatus.ON_HOLD.value
+ self.save()
+
+ trigger_event('returnorder.hold', id=self.pk)
+
+ @property
+ def can_cancel(self):
+ """Return True if this order can be cancelled."""
+ return self.status in ReturnOrderStatusGroups.OPEN
+
def _action_cancel(self, *args, **kwargs):
"""Cancel this ReturnOrder (if not already cancelled)."""
- if self.status != ReturnOrderStatus.CANCELLED:
+ if self.can_cancel:
self.status = ReturnOrderStatus.CANCELLED.value
self.save()
@@ -2151,7 +2236,7 @@ class ReturnOrder(TotalPriceMixin, Order):
def _action_complete(self, *args, **kwargs):
"""Complete this ReturnOrder (if not already completed)."""
- if self.status == ReturnOrderStatus.IN_PROGRESS:
+ if self.status == ReturnOrderStatus.IN_PROGRESS.value:
self.status = ReturnOrderStatus.COMPLETE.value
self.complete_date = InvenTree.helpers.current_date()
self.save()
@@ -2162,15 +2247,30 @@ class ReturnOrder(TotalPriceMixin, Order):
"""Deprecated version of 'issue_order."""
self.issue_order()
+ @property
+ def can_issue(self):
+ """Return True if this order can be issued."""
+ return self.status in [
+ ReturnOrderStatus.PENDING.value,
+ ReturnOrderStatus.ON_HOLD.value,
+ ]
+
def _action_place(self, *args, **kwargs):
"""Issue this ReturnOrder (if currently pending)."""
- if self.status == ReturnOrderStatus.PENDING:
+ if self.can_issue:
self.status = ReturnOrderStatus.IN_PROGRESS.value
self.issue_date = InvenTree.helpers.current_date()
self.save()
trigger_event('returnorder.issued', id=self.pk)
+ @transaction.atomic
+ def hold_order(self):
+ """Attempt to tranasition to ON_HOLD status."""
+ return self.handle_transition(
+ self.status, ReturnOrderStatus.ON_HOLD.value, self, self._action_hold
+ )
+
@transaction.atomic
def issue_order(self):
"""Attempt to transition to IN_PROGRESS status."""
diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py
index f8ed5c6f39..79e7d9043e 100644
--- a/src/backend/InvenTree/order/serializers.py
+++ b/src/backend/InvenTree/order/serializers.py
@@ -284,14 +284,37 @@ class PurchaseOrderSerializer(
)
-class PurchaseOrderCancelSerializer(serializers.Serializer):
- """Serializer for cancelling a PurchaseOrder."""
+class OrderAdjustSerializer(serializers.Serializer):
+ """Generic serializer class for adjusting the status of an order."""
class Meta:
- """Metaclass options."""
+ """Metaclass options.
+
+ By default, there are no fields required for this serializer type.
+ """
fields = []
+ @property
+ def order(self):
+ """Return the order object associated with this serializer.
+
+ Note: It is passed in via the serializer context data.
+ """
+ return self.context['order']
+
+
+class PurchaseOrderHoldSerializer(OrderAdjustSerializer):
+ """Serializer for placing a PurchaseOrder on hold."""
+
+ def save(self):
+ """Save the serializer to 'hold' the order."""
+ self.order.hold_order()
+
+
+class PurchaseOrderCancelSerializer(OrderAdjustSerializer):
+ """Serializer for cancelling a PurchaseOrder."""
+
def get_context_data(self):
"""Return custom context information about the order."""
self.order = self.context['order']
@@ -300,21 +323,19 @@ class PurchaseOrderCancelSerializer(serializers.Serializer):
def save(self):
"""Save the serializer to 'cancel' the order."""
- order = self.context['order']
-
- if not order.can_cancel:
+ if not self.order.can_cancel:
raise ValidationError(_('Order cannot be cancelled'))
- order.cancel_order()
+ self.order.cancel_order()
-class PurchaseOrderCompleteSerializer(serializers.Serializer):
+class PurchaseOrderCompleteSerializer(OrderAdjustSerializer):
"""Serializer for completing a purchase order."""
class Meta:
"""Metaclass options."""
- fields = []
+ fields = ['accept_incomplete']
accept_incomplete = serializers.BooleanField(
label=_('Accept Incomplete'),
@@ -340,22 +361,15 @@ class PurchaseOrderCompleteSerializer(serializers.Serializer):
def save(self):
"""Save the serializer to 'complete' the order."""
- order = self.context['order']
- order.complete_order()
+ self.order.complete_order()
-class PurchaseOrderIssueSerializer(serializers.Serializer):
+class PurchaseOrderIssueSerializer(OrderAdjustSerializer):
"""Serializer for issuing (sending) a purchase order."""
- class Meta:
- """Metaclass options."""
-
- fields = []
-
def save(self):
"""Save the serializer to 'place' the order."""
- order = self.context['order']
- order.place_order()
+ self.order.place_order()
@register_importer()
@@ -402,7 +416,6 @@ class PurchaseOrderLineItemSerializer(
def __init__(self, *args, **kwargs):
"""Initialization routine for the serializer."""
part_detail = kwargs.pop('part_detail', False)
-
order_detail = kwargs.pop('order_detail', False)
super().__init__(*args, **kwargs)
@@ -436,6 +449,18 @@ class PurchaseOrderLineItemSerializer(
)
)
+ queryset = queryset.prefetch_related(
+ 'order',
+ 'order__responsible',
+ 'order__stock_items',
+ 'part__tags',
+ 'part__supplier',
+ 'part__manufacturer_part',
+ 'part__manufacturer_part__manufacturer',
+ 'part__part__pricing_data',
+ 'part__part__tags',
+ )
+
queryset = queryset.annotate(
total_price=ExpressionWrapper(
F('purchase_price') * F('quantity'), output_field=models.DecimalField()
@@ -489,7 +514,7 @@ class PurchaseOrderLineItemSerializer(
)
supplier_part_detail = SupplierPartSerializer(
- source='part', many=False, read_only=True
+ source='part', brief=True, many=False, read_only=True
)
purchase_price = InvenTreeMoneySerializer(allow_null=True)
@@ -898,18 +923,12 @@ class SalesOrderSerializer(
)
-class SalesOrderIssueSerializer(serializers.Serializer):
+class SalesOrderIssueSerializer(OrderAdjustSerializer):
"""Serializer for issuing a SalesOrder."""
- class Meta:
- """Metaclass options."""
-
- fields = []
-
def save(self):
"""Save the serializer to 'issue' the order."""
- order = self.context['order']
- order.issue_order()
+ self.order.issue_order()
class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
@@ -1313,9 +1332,14 @@ class SalesOrderShipmentAllocationItemSerializer(serializers.Serializer):
return data
-class SalesOrderCompleteSerializer(serializers.Serializer):
+class SalesOrderCompleteSerializer(OrderAdjustSerializer):
"""DRF serializer for manually marking a sales order as complete."""
+ class Meta:
+ """Serializer metaclass options."""
+
+ fields = ['accept_incomplete']
+
accept_incomplete = serializers.BooleanField(
label=_('Accept Incomplete'),
help_text=_('Allow order to be closed with incomplete line items'),
@@ -1344,10 +1368,7 @@ class SalesOrderCompleteSerializer(serializers.Serializer):
def validate(self, data):
"""Custom validation for the serializer."""
data = super().validate(data)
-
- order = self.context['order']
-
- order.can_complete(
+ self.order.can_complete(
raise_error=True,
allow_incomplete_lines=str2bool(data.get('accept_incomplete', False)),
)
@@ -1357,17 +1378,24 @@ class SalesOrderCompleteSerializer(serializers.Serializer):
def save(self):
"""Save the serializer to complete the SalesOrder."""
request = self.context['request']
- order = self.context['order']
data = self.validated_data
user = getattr(request, 'user', None)
- order.ship_order(
+ self.order.ship_order(
user, allow_incomplete_lines=str2bool(data.get('accept_incomplete', False))
)
-class SalesOrderCancelSerializer(serializers.Serializer):
+class SalesOrderHoldSerializer(OrderAdjustSerializer):
+ """Serializer for placing a SalesOrder on hold."""
+
+ def save(self):
+ """Save the serializer to place the SalesOrder on hold."""
+ self.order.hold_order()
+
+
+class SalesOrderCancelSerializer(OrderAdjustSerializer):
"""Serializer for marking a SalesOrder as cancelled."""
def get_context_data(self):
@@ -1378,9 +1406,7 @@ class SalesOrderCancelSerializer(serializers.Serializer):
def save(self):
"""Save the serializer to cancel the order."""
- order = self.context['order']
-
- order.cancel_order()
+ self.order.cancel_order()
class SalesOrderSerialAllocationSerializer(serializers.Serializer):
@@ -1657,46 +1683,36 @@ class ReturnOrderSerializer(
)
-class ReturnOrderIssueSerializer(serializers.Serializer):
+class ReturnOrderHoldSerializer(OrderAdjustSerializer):
+ """Serializers for holding a ReturnOrder."""
+
+ def save(self):
+ """Save the serializer to 'hold' the order."""
+ self.order.hold_order()
+
+
+class ReturnOrderIssueSerializer(OrderAdjustSerializer):
"""Serializer for issuing a ReturnOrder."""
- class Meta:
- """Metaclass options."""
-
- fields = []
-
def save(self):
"""Save the serializer to 'issue' the order."""
- order = self.context['order']
- order.issue_order()
+ self.order.issue_order()
-class ReturnOrderCancelSerializer(serializers.Serializer):
+class ReturnOrderCancelSerializer(OrderAdjustSerializer):
"""Serializer for cancelling a ReturnOrder."""
- class Meta:
- """Metaclass options."""
-
- fields = []
-
def save(self):
"""Save the serializer to 'cancel' the order."""
- order = self.context['order']
- order.cancel_order()
+ self.order.cancel_order()
-class ReturnOrderCompleteSerializer(serializers.Serializer):
+class ReturnOrderCompleteSerializer(OrderAdjustSerializer):
"""Serializer for completing a ReturnOrder."""
- class Meta:
- """Metaclass options."""
-
- fields = []
-
def save(self):
"""Save the serializer to 'complete' the order."""
- order = self.context['order']
- order.complete_order()
+ self.order.complete_order()
class ReturnOrderLineItemReceiveSerializer(serializers.Serializer):
diff --git a/src/backend/InvenTree/order/status_codes.py b/src/backend/InvenTree/order/status_codes.py
index cec286dc09..bc5df2bca3 100644
--- a/src/backend/InvenTree/order/status_codes.py
+++ b/src/backend/InvenTree/order/status_codes.py
@@ -11,6 +11,7 @@ class PurchaseOrderStatus(StatusCode):
# Order status codes
PENDING = 10, _('Pending'), 'secondary' # Order is pending (not yet placed)
PLACED = 20, _('Placed'), 'primary' # Order has been placed with supplier
+ ON_HOLD = 25, _('On Hold'), 'warning' # Order is on hold
COMPLETE = 30, _('Complete'), 'success' # Order has been completed
CANCELLED = 40, _('Cancelled'), 'danger' # Order was cancelled
LOST = 50, _('Lost'), 'warning' # Order was lost
@@ -21,7 +22,11 @@ class PurchaseOrderStatusGroups:
"""Groups for PurchaseOrderStatus codes."""
# Open orders
- OPEN = [PurchaseOrderStatus.PENDING.value, PurchaseOrderStatus.PLACED.value]
+ OPEN = [
+ PurchaseOrderStatus.PENDING.value,
+ PurchaseOrderStatus.ON_HOLD.value,
+ PurchaseOrderStatus.PLACED.value,
+ ]
# Failed orders
FAILED = [
@@ -41,6 +46,7 @@ class SalesOrderStatus(StatusCode):
'primary',
) # Order has been issued, and is in progress
SHIPPED = 20, _('Shipped'), 'success' # Order has been shipped to customer
+ ON_HOLD = 25, _('On Hold'), 'warning' # Order is on hold
COMPLETE = 30, _('Complete'), 'success' # Order is complete
CANCELLED = 40, _('Cancelled'), 'danger' # Order has been cancelled
LOST = 50, _('Lost'), 'warning' # Order was lost
@@ -51,7 +57,11 @@ class SalesOrderStatusGroups:
"""Groups for SalesOrderStatus codes."""
# Open orders
- OPEN = [SalesOrderStatus.PENDING.value, SalesOrderStatus.IN_PROGRESS.value]
+ OPEN = [
+ SalesOrderStatus.PENDING.value,
+ SalesOrderStatus.ON_HOLD.value,
+ SalesOrderStatus.IN_PROGRESS.value,
+ ]
# Completed orders
COMPLETE = [SalesOrderStatus.SHIPPED.value, SalesOrderStatus.COMPLETE.value]
@@ -66,6 +76,8 @@ class ReturnOrderStatus(StatusCode):
# Items have been received, and are being inspected
IN_PROGRESS = 20, _('In Progress'), 'primary'
+ ON_HOLD = 25, _('On Hold'), 'warning'
+
COMPLETE = 30, _('Complete'), 'success'
CANCELLED = 40, _('Cancelled'), 'danger'
@@ -73,7 +85,11 @@ class ReturnOrderStatus(StatusCode):
class ReturnOrderStatusGroups:
"""Groups for ReturnOrderStatus codes."""
- OPEN = [ReturnOrderStatus.PENDING.value, ReturnOrderStatus.IN_PROGRESS.value]
+ OPEN = [
+ ReturnOrderStatus.PENDING.value,
+ ReturnOrderStatus.ON_HOLD.value,
+ ReturnOrderStatus.IN_PROGRESS.value,
+ ]
class ReturnOrderLineStatus(StatusCode):
diff --git a/src/backend/InvenTree/order/templates/order/order_base.html b/src/backend/InvenTree/order/templates/order/order_base.html
index ce9bc02fad..b02aafa19b 100644
--- a/src/backend/InvenTree/order/templates/order/order_base.html
+++ b/src/backend/InvenTree/order/templates/order/order_base.html
@@ -63,23 +63,28 @@
{% trans "Edit order" %}
- {% if order.can_cancel %}
-
- {% trans "Cancel order" %}
-
- {% endif %}
{% if roles.purchase_order.add %}
{% trans "Duplicate order" %}
{% endif %}
+ {% if order.can_hold %}
+
+ {% trans "Hold order" %}
+
+ {% endif %}
+ {% if order.can_cancel %}
+
+ {% trans "Cancel order" %}
+
+ {% endif %}
-{% if order.is_pending %}
+{% if order.can_issue %}
{% trans "Issue Order" %}
-{% elif order.is_open %}
+{% elif order.status == PurchaseOrderStatus.PLACED %}
{% trans "Complete Order" %}
@@ -238,7 +243,7 @@ src="{% static 'img/blank_image.png' %}"
{% block js_ready %}
{{ block.super }}
-{% if order.status == PurchaseOrderStatus.PENDING %}
+{% if order.status == PurchaseOrderStatus.PENDING or order.status == PurchaseOrderStatus.ON_HOLD %}
$("#place-order").click(function() {
issuePurchaseOrder(
@@ -281,6 +286,7 @@ $("#complete-order").click(function() {
);
});
+{% if order.can_cancel %}
$("#cancel-order").click(function() {
cancelPurchaseOrder(
@@ -292,6 +298,21 @@ $("#cancel-order").click(function() {
},
);
});
+{% endif %}
+
+{% if order.can_hold %}
+$("#hold-order").click(function() {
+
+ holdOrder(
+ '{% url "api-po-hold" order.pk %}',
+ {
+ onSuccess: function() {
+ window.location.reload();
+ }
+ }
+ );
+});
+{% endif %}
{% endif %}
diff --git a/src/backend/InvenTree/order/templates/order/return_order_base.html b/src/backend/InvenTree/order/templates/order/return_order_base.html
index f4590c71eb..494701abf1 100644
--- a/src/backend/InvenTree/order/templates/order/return_order_base.html
+++ b/src/backend/InvenTree/order/templates/order/return_order_base.html
@@ -74,11 +74,14 @@ src="{% static 'img/blank_image.png' %}"
- {% if order.status == ReturnOrderStatus.PENDING %}
+ {% if order.can_issue %}
{% trans "Issue Order" %}
@@ -211,7 +214,7 @@ src="{% static 'img/blank_image.png' %}"
{% if roles.return_order.change %}
-{% if order.status == ReturnOrderStatus.PENDING %}
+{% if order.can_issue %}
$('#issue-order').click(function() {
issueReturnOrder({{ order.pk }}, {
reload: true,
@@ -234,7 +237,7 @@ $('#edit-order').click(function() {
});
});
-{% if order.is_open %}
+{% if order.can_cancel %}
$('#cancel-order').click(function() {
cancelReturnOrder(
{{ order.pk }},
@@ -244,6 +247,17 @@ $('#cancel-order').click(function() {
);
});
{% endif %}
+
+{% if order.can_hold %}
+$("#hold-order").click(function() {
+ holdOrder(
+ '{% url "api-ro-hold" order.pk %}',
+ {
+ reload: true,
+ }
+ );
+ });
+{% endif %}
{% endif %}
{% if report_enabled %}
diff --git a/src/backend/InvenTree/order/templates/order/sales_order_base.html b/src/backend/InvenTree/order/templates/order/sales_order_base.html
index 2a3e676e95..c8d0179aa1 100644
--- a/src/backend/InvenTree/order/templates/order/sales_order_base.html
+++ b/src/backend/InvenTree/order/templates/order/sales_order_base.html
@@ -73,13 +73,16 @@ src="{% static 'img/blank_image.png' %}"
- {% if order.is_pending %}
+ {% if order.status == SalesOrderStatus.PENDING or order.status == SalesOrderStatus.ON_HOLD %}
{% trans "Issue Order" %}
@@ -280,6 +283,7 @@ $('#issue-order').click(function() {
);
});
+{% if order.can_cancel %}
$("#cancel-order").click(function() {
cancelSalesOrder(
@@ -289,6 +293,20 @@ $("#cancel-order").click(function() {
}
);
});
+{% endif %}
+
+{% if order.can_hold %}
+$('#hold-order').click(function() {
+ holdOrder(
+ '{% url "api-so-hold" order.pk %}',
+ {
+ onSuccess: function() {
+ window.location.reload();
+ }
+ }
+ );
+});
+{% endif %}
$("#ship-order").click(function() {
shipSalesOrder(
diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py
index 20d28b9f47..b84b24f70e 100644
--- a/src/backend/InvenTree/part/models.py
+++ b/src/backend/InvenTree/part/models.py
@@ -51,8 +51,7 @@ from build import models as BuildModels
from build.status_codes import BuildStatusGroups
from common.currency import currency_code_default
from common.icons import validate_icon
-from common.models import InvenTreeSetting
-from common.settings import get_global_setting, set_global_setting
+from common.settings import get_global_setting
from company.models import SupplierPart
from InvenTree import helpers, validators
from InvenTree.fields import InvenTreeURLField
diff --git a/src/backend/InvenTree/templates/js/translated/order.js b/src/backend/InvenTree/templates/js/translated/order.js
index 8c98a729ce..fc7c41960f 100644
--- a/src/backend/InvenTree/templates/js/translated/order.js
+++ b/src/backend/InvenTree/templates/js/translated/order.js
@@ -8,6 +8,7 @@
exportFormatOptions,
formatCurrency,
getFormFieldValue,
+ handleFormSuccess,
inventreeGet,
inventreeLoad,
inventreeSave,
@@ -25,6 +26,7 @@
createExtraLineItem,
editExtraLineItem,
exportOrder,
+ holdOrder,
issuePurchaseOrder,
newPurchaseOrderFromOrderWizard,
newSupplierPartFromOrderWizard,
@@ -38,6 +40,29 @@
*/
+function holdOrder(url, options={}) {
+ constructForm(
+ url,
+ {
+ method: 'POST',
+ title: '{% trans "Hold Order" %}',
+ confirm: true,
+ preFormContent: function(opts) {
+ let html = `
+
+ {% trans "Are you sure you wish to place this order on hold?" %}
+
`;
+
+ return html;
+ },
+ onSuccess: function(response) {
+ handleFormSuccess(response, options);
+ }
+ }
+ );
+}
+
+
/* Construct a set of fields for a OrderExtraLine form */
function extraLineFields(options={}) {
diff --git a/src/frontend/src/components/buttons/PrimaryActionButton.tsx b/src/frontend/src/components/buttons/PrimaryActionButton.tsx
new file mode 100644
index 0000000000..8d30b3fd36
--- /dev/null
+++ b/src/frontend/src/components/buttons/PrimaryActionButton.tsx
@@ -0,0 +1,41 @@
+import { Button, Tooltip } from '@mantine/core';
+
+import { InvenTreeIcon, InvenTreeIconType } from '../../functions/icons';
+import { notYetImplemented } from '../../functions/notifications';
+
+/**
+ * A "primary action" button for display on a page detail, (for example)
+ */
+export default function PrimaryActionButton({
+ title,
+ tooltip,
+ icon,
+ color,
+ hidden,
+ onClick
+}: {
+ title: string;
+ tooltip?: string;
+ icon?: InvenTreeIconType;
+ color?: string;
+ hidden?: boolean;
+ onClick?: () => void;
+}) {
+ if (hidden) {
+ return null;
+ }
+
+ return (
+
+ }
+ color={color}
+ radius="sm"
+ p="xs"
+ onClick={onClick ?? notYetImplemented}
+ >
+ {title}
+
+
+ );
+}
diff --git a/src/frontend/src/components/forms/ApiForm.tsx b/src/frontend/src/components/forms/ApiForm.tsx
index 609b7b2b57..bfff18714e 100644
--- a/src/frontend/src/components/forms/ApiForm.tsx
+++ b/src/frontend/src/components/forms/ApiForm.tsx
@@ -368,6 +368,11 @@ export function ApiForm({
return;
}
+ // Do not auto-focus on a 'choice' field
+ if (field.field_type == 'choice') {
+ return;
+ }
+
focusField = fieldName;
});
}
@@ -378,7 +383,7 @@ export function ApiForm({
form.setFocus(focusField);
setInitialFocus(focusField);
- }, [props.focus, fields, form.setFocus, isLoading, initialFocus]);
+ }, [props.focus, form.setFocus, isLoading, initialFocus]);
const submitForm: SubmitHandler
= async (data) => {
setNonFieldErrors([]);
diff --git a/src/frontend/src/components/importer/ImporterDrawer.tsx b/src/frontend/src/components/importer/ImporterDrawer.tsx
index ae46780604..5aa39f67df 100644
--- a/src/frontend/src/components/importer/ImporterDrawer.tsx
+++ b/src/frontend/src/components/importer/ImporterDrawer.tsx
@@ -16,10 +16,9 @@ import {
import { IconCheck } from '@tabler/icons-react';
import { ReactNode, useMemo } from 'react';
-import {
- ImportSessionStatus,
- useImportSession
-} from '../../hooks/UseImportSession';
+import { ModelType } from '../../enums/ModelType';
+import { useImportSession } from '../../hooks/UseImportSession';
+import useStatusCodes from '../../hooks/UseStatusCodes';
import { StylishText } from '../items/StylishText';
import ImporterDataSelector from './ImportDataSelector';
import ImporterColumnSelector from './ImporterColumnSelector';
@@ -62,19 +61,23 @@ export default function ImporterDrawer({
}) {
const session = useImportSession({ sessionId: sessionId });
+ const importSessionStatus = useStatusCodes({
+ modelType: ModelType.importsession
+ });
+
// Map from import steps to stepper steps
const currentStep = useMemo(() => {
switch (session.status) {
default:
- case ImportSessionStatus.INITIAL:
+ case importSessionStatus.INITIAL:
return 0;
- case ImportSessionStatus.MAPPING:
+ case importSessionStatus.MAPPING:
return 1;
- case ImportSessionStatus.IMPORTING:
+ case importSessionStatus.IMPORTING:
return 2;
- case ImportSessionStatus.PROCESSING:
+ case importSessionStatus.PROCESSING:
return 3;
- case ImportSessionStatus.COMPLETE:
+ case importSessionStatus.COMPLETE:
return 4;
}
}, [session.status]);
@@ -85,15 +88,15 @@ export default function ImporterDrawer({
}
switch (session.status) {
- case ImportSessionStatus.INITIAL:
+ case importSessionStatus.INITIAL:
return Initial : TODO ;
- case ImportSessionStatus.MAPPING:
+ case importSessionStatus.MAPPING:
return ;
- case ImportSessionStatus.IMPORTING:
+ case importSessionStatus.IMPORTING:
return ;
- case ImportSessionStatus.PROCESSING:
+ case importSessionStatus.PROCESSING:
return ;
- case ImportSessionStatus.COMPLETE:
+ case importSessionStatus.COMPLETE:
return (
{
- console.log('refreshing:', session.status);
-
- if (session.status == ImportSessionStatus.IMPORTING) {
+ if (session.status == importSessionStatus.IMPORTING) {
session.refreshSession();
}
}, 1000);
diff --git a/src/frontend/src/components/items/ActionDropdown.tsx b/src/frontend/src/components/items/ActionDropdown.tsx
index d29c21c93f..f291bd520e 100644
--- a/src/frontend/src/components/items/ActionDropdown.tsx
+++ b/src/frontend/src/components/items/ActionDropdown.tsx
@@ -89,7 +89,11 @@ export function ActionDropdown({
{...action.indicator}
key={action.name}
>
-
+
void;
+}): ActionDropdownItem {
+ return {
+ icon: ,
+ name: t`Hold`,
+ tooltip: tooltip ?? t`Hold`,
+ onClick: onClick,
+ hidden: hidden
+ };
+}
+
export function CancelItemAction({
hidden = false,
tooltip,
diff --git a/src/frontend/src/components/render/User.tsx b/src/frontend/src/components/render/User.tsx
index 16fdb9710e..b969e203e1 100644
--- a/src/frontend/src/components/render/User.tsx
+++ b/src/frontend/src/components/render/User.tsx
@@ -10,7 +10,13 @@ export function RenderOwner({
instance && (
: }
+ suffix={
+ instance.label == 'group' ? (
+
+ ) : (
+
+ )
+ }
/>
)
);
diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx
index 14d40cd9c9..80ae3443ae 100644
--- a/src/frontend/src/enums/ApiEndpoints.tsx
+++ b/src/frontend/src/enums/ApiEndpoints.tsx
@@ -62,9 +62,12 @@ export enum ApiEndpoints {
// Build API endpoints
build_order_list = 'build/',
+ build_order_issue = 'build/:id/issue/',
build_order_cancel = 'build/:id/cancel/',
- build_output_create = 'build/:id/create-output/',
+ build_order_hold = 'build/:id/hold/',
+ build_order_complete = 'build/:id/finish/',
build_output_complete = 'build/:id/complete/',
+ build_output_create = 'build/:id/create-output/',
build_output_scrap = 'build/:id/scrap-outputs/',
build_output_delete = 'build/:id/delete-outputs/',
build_line_list = 'build/line/',
@@ -124,14 +127,27 @@ export enum ApiEndpoints {
// Order API endpoints
purchase_order_list = 'order/po/',
+ purchase_order_issue = 'order/po/:id/issue/',
+ purchase_order_hold = 'order/po/:id/hold/',
+ purchase_order_cancel = 'order/po/:id/cancel/',
+ purchase_order_complete = 'order/po/:id/complete/',
purchase_order_line_list = 'order/po-line/',
purchase_order_receive = 'order/po/:id/receive/',
sales_order_list = 'order/so/',
+ sales_order_issue = 'order/so/:id/issue/',
+ sales_order_hold = 'order/so/:id/hold/',
+ sales_order_cancel = 'order/so/:id/cancel/',
+ sales_order_ship = 'order/so/:id/ship/',
+ sales_order_complete = 'order/so/:id/complete/',
sales_order_line_list = 'order/so-line/',
sales_order_shipment_list = 'order/so/shipment/',
return_order_list = 'order/ro/',
+ return_order_issue = 'order/ro/:id/issue/',
+ return_order_hold = 'order/ro/:id/hold/',
+ return_order_cancel = 'order/ro/:id/cancel/',
+ return_order_complete = 'order/ro/:id/complete/',
return_order_line_list = 'order/ro-line/',
// Template API endpoints
diff --git a/src/frontend/src/forms/BuildForms.tsx b/src/frontend/src/forms/BuildForms.tsx
index 16d8e948f1..9b98bb04a4 100644
--- a/src/frontend/src/forms/BuildForms.tsx
+++ b/src/frontend/src/forms/BuildForms.tsx
@@ -21,6 +21,7 @@ import { InvenTreeIcon } from '../functions/icons';
import { useCreateApiFormModal } from '../hooks/UseForm';
import { useBatchCodeGenerator } from '../hooks/UseGenerator';
import { apiUrl } from '../states/ApiState';
+import { useGlobalSettingsState } from '../states/SettingsState';
import { PartColumn, StatusColumn } from '../tables/ColumnRenderers';
/**
@@ -43,6 +44,8 @@ export function useBuildOrderFields({
}
});
+ const globalSettings = useGlobalSettingsState();
+
return useMemo(() => {
return {
reference: {},
@@ -50,7 +53,13 @@ export function useBuildOrderFields({
disabled: !create,
filters: {
assembly: true,
- virtual: false
+ virtual: false,
+ active: globalSettings.isSet('BUILDORDER_REQUIRE_ACTIVE_PART')
+ ? true
+ : undefined,
+ locked: globalSettings.isSet('BUILDORDER_REQUIRE_LOCKED_PART')
+ ? true
+ : undefined
},
onValueChange(value: any, record?: any) {
// Adjust the destination location for the build order
@@ -107,7 +116,7 @@ export function useBuildOrderFields({
}
}
};
- }, [create, destination, batchCode]);
+ }, [create, destination, batchCode, globalSettings]);
}
export function useBuildOrderOutputFields({
diff --git a/src/frontend/src/functions/icons.tsx b/src/frontend/src/functions/icons.tsx
index 587a244eb8..5f4fd8cb4e 100644
--- a/src/frontend/src/functions/icons.tsx
+++ b/src/frontend/src/functions/icons.tsx
@@ -6,6 +6,7 @@ import {
IconBinaryTree2,
IconBookmarks,
IconBox,
+ IconBrandTelegram,
IconBuilding,
IconBuildingFactory2,
IconBuildingStore,
@@ -32,6 +33,7 @@ import {
IconFlagShare,
IconGitBranch,
IconGridDots,
+ IconHandStop,
IconHash,
IconHierarchy,
IconInfoCircle,
@@ -142,6 +144,10 @@ const icons = {
plus: IconCirclePlus,
minus: IconCircleMinus,
cancel: IconCircleX,
+ hold: IconHandStop,
+ issue: IconBrandTelegram,
+ complete: IconCircleCheck,
+ deliver: IconTruckDelivery,
// Part Icons
active: IconCheck,
diff --git a/src/frontend/src/hooks/UseImportSession.tsx b/src/frontend/src/hooks/UseImportSession.tsx
index bb5391eaeb..f12adc4566 100644
--- a/src/frontend/src/hooks/UseImportSession.tsx
+++ b/src/frontend/src/hooks/UseImportSession.tsx
@@ -1,28 +1,21 @@
import { useCallback, useMemo } from 'react';
import { ApiEndpoints } from '../enums/ApiEndpoints';
+import { ModelType } from '../enums/ModelType';
import { useInstance } from './UseInstance';
+import useStatusCodes from './UseStatusCodes';
/*
* Custom hook for managing the state of a data import session
*/
-// TODO: Load these values from the server?
-export enum ImportSessionStatus {
- INITIAL = 0,
- MAPPING = 10,
- IMPORTING = 20,
- PROCESSING = 30,
- COMPLETE = 40
-}
-
export type ImportSessionState = {
sessionId: number;
sessionData: any;
setSessionData: (data: any) => void;
refreshSession: () => void;
sessionQuery: any;
- status: ImportSessionStatus;
+ status: number;
availableFields: Record;
availableColumns: string[];
mappedFields: any[];
@@ -52,15 +45,17 @@ export function useImportSession({
});
const setSessionData = useCallback((data: any) => {
- console.log('setting session data:');
- console.log(data);
setInstance(data);
}, []);
+ const importSessionStatus = useStatusCodes({
+ modelType: ModelType.importsession
+ });
+
// Current step of the import process
- const status: ImportSessionStatus = useMemo(() => {
- return sessionData?.status ?? ImportSessionStatus.INITIAL;
- }, [sessionData]);
+ const status: number = useMemo(() => {
+ return sessionData?.status ?? importSessionStatus.INITIAL;
+ }, [sessionData, importSessionStatus]);
// List of available writeable database field definitions
const availableFields: any[] = useMemo(() => {
diff --git a/src/frontend/src/hooks/UseStatusCodes.tsx b/src/frontend/src/hooks/UseStatusCodes.tsx
new file mode 100644
index 0000000000..b581aa7502
--- /dev/null
+++ b/src/frontend/src/hooks/UseStatusCodes.tsx
@@ -0,0 +1,46 @@
+import { useMemo } from 'react';
+
+import { getStatusCodes } from '../components/render/StatusRenderer';
+import { ModelType } from '../enums/ModelType';
+import { useGlobalStatusState } from '../states/StatusState';
+
+/**
+ * Hook to access status codes, which are enumerated by the backend.
+ *
+ * This hook is used to return a map of status codes for a given model type.
+ * It is a memoized wrapper around getStatusCodes,
+ * and returns a simplified KEY:value map of status codes.
+ *
+ * e.g. for the "PurchaseOrderStatus" enumeration, returns a map like:
+ *
+ * {
+ * PENDING: 10
+ * PLACED: 20
+ * ON_HOLD: 25,
+ * COMPLETE: 30,
+ * CANCELLED: 40,
+ * LOST: 50,
+ * RETURNED: 60
+ * }
+ */
+export default function useStatusCodes({
+ modelType
+}: {
+ modelType: ModelType | string;
+}) {
+ const statusCodeList = useGlobalStatusState.getState().status;
+
+ const codes = useMemo(() => {
+ const statusCodes = getStatusCodes(modelType) || {};
+
+ let codesMap: Record = {};
+
+ for (let name in statusCodes) {
+ codesMap[name] = statusCodes[name].key;
+ }
+
+ return codesMap;
+ }, [modelType, statusCodeList]);
+
+ return codes;
+}
diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx
index 550d795dcb..0896a39d70 100644
--- a/src/frontend/src/pages/build/BuildDetail.tsx
+++ b/src/frontend/src/pages/build/BuildDetail.tsx
@@ -19,6 +19,7 @@ import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import AdminButton from '../../components/buttons/AdminButton';
+import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
@@ -30,6 +31,7 @@ import {
CancelItemAction,
DuplicateItemAction,
EditItemAction,
+ HoldItemAction,
LinkBarcodeAction,
UnlinkBarcodeAction,
ViewBarcodeAction
@@ -47,6 +49,7 @@ import {
useEditApiFormModal
} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
+import useStatusCodes from '../../hooks/UseStatusCodes';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import BuildAllocatedStockTable from '../../tables/build/BuildAllocatedStockTable';
@@ -364,21 +367,7 @@ export default function BuildDetail() {
pk: build.pk,
title: t`Edit Build Order`,
fields: buildOrderFields,
- onFormSuccess: () => {
- refreshInstance();
- }
- });
-
- const cancelBuild = useCreateApiFormModal({
- url: apiUrl(ApiEndpoints.build_order_cancel, build.pk),
- title: t`Cancel Build Order`,
- fields: {
- remove_allocated_stock: {},
- remove_incomplete_outputs: {}
- },
- onFormSuccess: () => {
- refreshInstance();
- }
+ onFormSuccess: refreshInstance
});
const duplicateBuild = useCreateApiFormModal({
@@ -393,8 +382,85 @@ export default function BuildDetail() {
modelType: ModelType.build
});
+ const buildStatus = useStatusCodes({ modelType: ModelType.build });
+
+ const cancelOrder = useCreateApiFormModal({
+ url: apiUrl(ApiEndpoints.build_order_cancel, build.pk),
+ title: t`Cancel Build Order`,
+ onFormSuccess: refreshInstance,
+ successMessage: t`Order cancelled`,
+ preFormWarning: t`Cancel this order`,
+ fields: {
+ remove_allocated_stock: {},
+ remove_incomplete_outputs: {}
+ }
+ });
+
+ const holdOrder = useCreateApiFormModal({
+ url: apiUrl(ApiEndpoints.build_order_hold, build.pk),
+ title: t`Hold Build Order`,
+ onFormSuccess: refreshInstance,
+ preFormWarning: t`Place this order on hold`,
+ successMessage: t`Order placed on hold`
+ });
+
+ const issueOrder = useCreateApiFormModal({
+ url: apiUrl(ApiEndpoints.build_order_issue, build.pk),
+ title: t`Issue Build Order`,
+ onFormSuccess: refreshInstance,
+ preFormWarning: t`Issue this order`,
+ successMessage: t`Order issued`
+ });
+
+ const completeOrder = useCreateApiFormModal({
+ url: apiUrl(ApiEndpoints.build_order_complete, build.pk),
+ title: t`Complete Build Order`,
+ onFormSuccess: refreshInstance,
+ preFormWarning: t`Mark this order as complete`,
+ successMessage: t`Order completed`,
+ fields: {
+ accept_overallocated: {},
+ accept_unallocated: {},
+ accept_incomplete: {}
+ }
+ });
+
const buildActions = useMemo(() => {
+ const canEdit = user.hasChangeRole(UserRoles.build);
+
+ const canIssue =
+ canEdit &&
+ (build.status == buildStatus.PENDING ||
+ build.status == buildStatus.ON_HOLD);
+
+ const canComplete = canEdit && build.status == buildStatus.PRODUCTION;
+
+ const canHold =
+ canEdit &&
+ (build.status == buildStatus.PENDING ||
+ build.status == buildStatus.PRODUCTION);
+
+ const canCancel =
+ canEdit &&
+ (build.status == buildStatus.PENDING ||
+ build.status == buildStatus.ON_HOLD ||
+ build.status == buildStatus.PRODUCTION);
+
return [
+ ,
+ ,
,
editBuild.open(),
- hidden: !user.hasChangeRole(UserRoles.build)
- }),
- CancelItemAction({
- tooltip: t`Cancel order`,
- onClick: () => cancelBuild.open(),
- hidden: !user.hasChangeRole(UserRoles.build)
- // TODO: Hide if build cannot be cancelled
+ hidden: !canEdit,
+ tooltip: t`Edit order`
}),
DuplicateItemAction({
onClick: () => duplicateBuild.open(),
+ tooltip: t`Duplicate order`,
hidden: !user.hasAddRole(UserRoles.build)
+ }),
+ HoldItemAction({
+ tooltip: t`Hold order`,
+ hidden: !canHold,
+ onClick: holdOrder.open
+ }),
+ CancelItemAction({
+ tooltip: t`Cancel order`,
+ onClick: cancelOrder.open,
+ hidden: !canCancel
})
]}
/>
];
- }, [id, build, user]);
+ }, [id, build, user, buildStatus]);
const buildBadges = useMemo(() => {
return instanceQuery.isFetching
@@ -454,7 +526,10 @@ export default function BuildDetail() {
<>
{editBuild.modal}
{duplicateBuild.modal}
- {cancelBuild.modal}
+ {cancelOrder.modal}
+ {holdOrder.modal}
+ {issueOrder.modal}
+ {completeOrder.modal}
{
return [
,
+ ,
}
diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx
index 409aa9d93b..9a59d2a72b 100644
--- a/src/frontend/src/pages/part/PartDetail.tsx
+++ b/src/frontend/src/pages/part/PartDetail.tsx
@@ -130,6 +130,13 @@ export default function PartDetail() {
icon: 'part',
copy: true
},
+ {
+ type: 'string',
+ name: 'IPN',
+ label: t`IPN`,
+ copy: true,
+ hidden: !part.IPN
+ },
{
type: 'string',
name: 'description',
@@ -177,13 +184,6 @@ export default function PartDetail() {
model: ModelType.stocklocation,
hidden: part.default_location || !part.category_default_location
},
- {
- type: 'string',
- name: 'IPN',
- label: t`IPN`,
- copy: true,
- hidden: !part.IPN
- },
{
type: 'string',
name: 'units',
@@ -799,7 +799,7 @@ export default function PartDetail() {
0}
+ visible={part.ordering > 0}
key="on_order"
/>,
{
+ const canEdit: boolean = user.hasChangeRole(UserRoles.purchase_order);
+
+ const canIssue: boolean =
+ canEdit &&
+ (order.status == poStatus.PENDING || order.status == poStatus.ON_HOLD);
+
+ const canHold: boolean =
+ canEdit &&
+ (order.status == poStatus.PENDING || order.status == poStatus.PLACED);
+
+ const canComplete: boolean = canEdit && order.status == poStatus.PLACED;
+
+ const canCancel: boolean =
+ canEdit &&
+ order.status != poStatus.CANCELLED &&
+ order.status != poStatus.COMPLETE;
+
return [
+ ,
+ ,
,
}
actions={[
EditItemAction({
- hidden: !user.hasChangeRole(UserRoles.purchase_order),
+ hidden: !canEdit,
+ tooltip: t`Edit order`,
onClick: () => {
editPurchaseOrder.open();
}
}),
- CancelItemAction({
- tooltip: t`Cancel order`
- }),
DuplicateItemAction({
hidden: !user.hasAddRole(UserRoles.purchase_order),
- onClick: () => duplicatePurchaseOrder.open()
+ onClick: () => duplicatePurchaseOrder.open(),
+ tooltip: t`Duplicate order`
+ }),
+ HoldItemAction({
+ tooltip: t`Hold order`,
+ hidden: !canHold,
+ onClick: holdOrder.open
+ }),
+ CancelItemAction({
+ tooltip: t`Cancel order`,
+ hidden: !canCancel,
+ onClick: cancelOrder.open
})
]}
/>
];
- }, [id, order, user]);
+ }, [id, order, user, poStatus]);
const orderBadges: ReactNode[] = useMemo(() => {
return instanceQuery.isLoading
@@ -345,7 +427,12 @@ export default function PurchaseOrderDetail() {
return (
<>
+ {issueOrder.modal}
+ {holdOrder.modal}
+ {cancelOrder.modal}
+ {completeOrder.modal}
{editPurchaseOrder.modal}
+ {duplicatePurchaseOrder.modal}
{
+ const canEdit: boolean = user.hasChangeRole(UserRoles.return_order);
+
+ const canIssue: boolean =
+ canEdit &&
+ (order.status == roStatus.PENDING || order.status == roStatus.ON_HOLD);
+
+ const canHold: boolean =
+ canEdit &&
+ (order.status == roStatus.PENDING ||
+ order.status == roStatus.PLACED ||
+ order.status == roStatus.IN_PROGRESS);
+
+ const canCancel: boolean =
+ canEdit &&
+ (order.status == roStatus.PENDING ||
+ order.status == roStatus.IN_PROGRESS ||
+ order.status == roStatus.ON_HOLD);
+
+ const canComplete: boolean =
+ canEdit && order.status == roStatus.IN_PROGRESS;
+
return [
+ issueOrder.open()}
+ />,
+ completeOrder.open()}
+ />,
,
{
editReturnOrder.open();
}
}),
- CancelItemAction({
- tooltip: t`Cancel order`
- }),
DuplicateItemAction({
+ tooltip: t`Duplicate order`,
hidden: !user.hasChangeRole(UserRoles.return_order),
onClick: () => duplicateReturnOrder.open()
+ }),
+ HoldItemAction({
+ tooltip: t`Hold order`,
+ hidden: !canHold,
+ onClick: () => holdOrder.open()
+ }),
+ CancelItemAction({
+ tooltip: t`Cancel order`,
+ hidden: !canCancel,
+ onClick: () => cancelOrder.open()
})
]}
/>
];
- }, [user, order]);
+ }, [user, order, roStatus]);
return (
<>
{editReturnOrder.modal}
+ {issueOrder.modal}
+ {cancelOrder.modal}
+ {holdOrder.modal}
+ {completeOrder.modal}
{duplicateReturnOrder.modal}
diff --git a/src/frontend/src/pages/sales/SalesOrderDetail.tsx b/src/frontend/src/pages/sales/SalesOrderDetail.tsx
index 6d4f35b1e3..fbafd71c22 100644
--- a/src/frontend/src/pages/sales/SalesOrderDetail.tsx
+++ b/src/frontend/src/pages/sales/SalesOrderDetail.tsx
@@ -13,6 +13,7 @@ import { ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import AdminButton from '../../components/buttons/AdminButton';
+import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
@@ -24,6 +25,7 @@ import {
CancelItemAction,
DuplicateItemAction,
EditItemAction,
+ HoldItemAction,
LinkBarcodeAction,
UnlinkBarcodeAction,
ViewBarcodeAction
@@ -42,6 +44,8 @@ import {
useEditApiFormModal
} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
+import useStatusCodes from '../../hooks/UseStatusCodes';
+import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
import { AttachmentTable } from '../../tables/general/AttachmentTable';
@@ -213,6 +217,8 @@ export default function SalesOrderDetail() {
);
}, [order, instanceQuery]);
+ const soStatus = useStatusCodes({ modelType: ModelType.salesorder });
+
const salesOrderFields = useSalesOrderFields();
const editSalesOrder = useEditApiFormModal({
@@ -253,6 +259,10 @@ export default function SalesOrderDetail() {
)
},
@@ -296,10 +306,86 @@ export default function SalesOrderDetail() {
)
}
];
- }, [order, id, user]);
+ }, [order, id, user, soStatus]);
+
+ const issueOrder = useCreateApiFormModal({
+ url: apiUrl(ApiEndpoints.sales_order_issue, order.pk),
+ title: t`Issue Sales Order`,
+ onFormSuccess: refreshInstance,
+ preFormWarning: t`Issue this order`,
+ successMessage: t`Order issued`
+ });
+
+ const cancelOrder = useCreateApiFormModal({
+ url: apiUrl(ApiEndpoints.sales_order_cancel, order.pk),
+ title: t`Cancel Sales Order`,
+ onFormSuccess: refreshInstance,
+ preFormWarning: t`Cancel this order`,
+ successMessage: t`Order cancelled`
+ });
+
+ const holdOrder = useCreateApiFormModal({
+ url: apiUrl(ApiEndpoints.sales_order_hold, order.pk),
+ title: t`Hold Sales Order`,
+ onFormSuccess: refreshInstance,
+ preFormWarning: t`Place this order on hold`,
+ successMessage: t`Order placed on hold`
+ });
+
+ const completeOrder = useCreateApiFormModal({
+ url: apiUrl(ApiEndpoints.sales_order_complete, order.pk),
+ title: t`Complete Sales Order`,
+ onFormSuccess: refreshInstance,
+ preFormWarning: t`Mark this order as complete`,
+ successMessage: t`Order completed`,
+ fields: {
+ accept_incomplete: {}
+ }
+ });
const soActions = useMemo(() => {
+ const canEdit: boolean = user.hasChangeRole(UserRoles.sales_order);
+
+ const canIssue: boolean =
+ canEdit &&
+ (order.status == soStatus.PENDING || order.status == soStatus.ON_HOLD);
+
+ const canCancel: boolean =
+ canEdit &&
+ (order.status == soStatus.PENDING ||
+ order.status == soStatus.ON_HOLD ||
+ order.status == soStatus.IN_PROGRESS);
+
+ const canHold: boolean =
+ canEdit &&
+ (order.status == soStatus.PENDING ||
+ order.status == soStatus.IN_PROGRESS);
+
+ const canShip: boolean = canEdit && order.status == soStatus.IN_PROGRESS;
+ const canComplete: boolean = canEdit && order.status == soStatus.SHIPPED;
+
return [
+ ,
+ ,
+ ,
,
}
actions={[
EditItemAction({
- hidden: !user.hasChangeRole(UserRoles.sales_order),
- onClick: () => editSalesOrder.open()
- }),
- CancelItemAction({
- tooltip: t`Cancel order`
+ hidden: !canEdit,
+ onClick: () => editSalesOrder.open(),
+ tooltip: t`Edit order`
}),
DuplicateItemAction({
hidden: !user.hasAddRole(UserRoles.sales_order),
- onClick: () => duplicateSalesOrder.open()
+ onClick: () => duplicateSalesOrder.open(),
+ tooltip: t`Duplicate order`
+ }),
+ HoldItemAction({
+ tooltip: t`Hold order`,
+ hidden: !canHold,
+ onClick: () => holdOrder.open()
+ }),
+ CancelItemAction({
+ tooltip: t`Cancel order`,
+ hidden: !canCancel,
+ onClick: () => cancelOrder.open()
})
]}
/>
];
- }, [user, order]);
+ }, [user, order, soStatus]);
const orderBadges: ReactNode[] = useMemo(() => {
return instanceQuery.isLoading
@@ -355,7 +450,12 @@ export default function SalesOrderDetail() {
return (
<>
+ {issueOrder.modal}
+ {cancelOrder.modal}
+ {holdOrder.modal}
+ {completeOrder.modal}
{editSalesOrder.modal}
+ {duplicateSalesOrder.modal}
PartColumn(record.part_detail)
},
+ {
+ accessor: 'part_detail.IPN',
+ sortable: false,
+ title: t`IPN`
+ },
{
accessor: 'sub_part',
sortable: true,
diff --git a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx
index 69e821471e..5beff3b52b 100644
--- a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx
+++ b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx
@@ -34,10 +34,12 @@ import { TableHoverCard } from '../TableHoverCard';
export default function SalesOrderLineItemTable({
orderId,
- customerId
+ customerId,
+ editable
}: {
orderId: number;
customerId: number;
+ editable: boolean;
}) {
const user = useUserState();
const table = useTable('sales-order-line-item');
@@ -207,7 +209,7 @@ export default function SalesOrderLineItemTable({
});
newLine.open();
}}
- hidden={!user.hasAddRole(UserRoles.sales_order)}
+ hidden={!editable || !user.hasAddRole(UserRoles.sales_order)}
/>
];
}, [user, orderId]);
@@ -218,7 +220,10 @@ export default function SalesOrderLineItemTable({
return [
{
- hidden: allocated || !user.hasChangeRole(UserRoles.sales_order),
+ hidden:
+ allocated ||
+ !editable ||
+ !user.hasChangeRole(UserRoles.sales_order),
title: t`Allocate stock`,
icon: ,
color: 'green'
@@ -242,21 +247,21 @@ export default function SalesOrderLineItemTable({
color: 'blue'
},
RowEditAction({
- hidden: !user.hasChangeRole(UserRoles.sales_order),
+ hidden: !editable || !user.hasChangeRole(UserRoles.sales_order),
onClick: () => {
setSelectedLine(record.pk);
editLine.open();
}
}),
RowDuplicateAction({
- hidden: !user.hasAddRole(UserRoles.sales_order),
+ hidden: !editable || !user.hasAddRole(UserRoles.sales_order),
onClick: () => {
setInitialData(record);
newLine.open();
}
}),
RowDeleteAction({
- hidden: !user.hasDeleteRole(UserRoles.sales_order),
+ hidden: !editable || !user.hasDeleteRole(UserRoles.sales_order),
onClick: () => {
setSelectedLine(record.pk);
deleteLine.open();
@@ -264,7 +269,7 @@ export default function SalesOrderLineItemTable({
})
];
},
- [user]
+ [user, editable]
);
return (
diff --git a/src/frontend/src/tables/sales/SalesOrderTable.tsx b/src/frontend/src/tables/sales/SalesOrderTable.tsx
index 732659e159..61e77dae55 100644
--- a/src/frontend/src/tables/sales/SalesOrderTable.tsx
+++ b/src/frontend/src/tables/sales/SalesOrderTable.tsx
@@ -19,6 +19,7 @@ import {
LineItemsProgressColumn,
ProjectCodeColumn,
ReferenceColumn,
+ ResponsibleColumn,
ShipmentDateColumn,
StatusColumn,
TargetDateColumn
@@ -129,6 +130,7 @@ export function SalesOrderTable({
CreationDateColumn({}),
TargetDateColumn({}),
ShipmentDateColumn({}),
+ ResponsibleColumn({}),
{
accessor: 'total_price',
title: t`Total Price`,
diff --git a/src/frontend/tests/baseFixtures.ts b/src/frontend/tests/baseFixtures.ts
index d164da1acc..e233712c88 100644
--- a/src/frontend/tests/baseFixtures.ts
+++ b/src/frontend/tests/baseFixtures.ts
@@ -59,6 +59,7 @@ export const test = baseTest.extend({
if (
msg.type() === 'error' &&
!msg.text().startsWith('ERR: ') &&
+ msg.text().indexOf('downloadable font: download failed') < 0 &&
msg
.text()
.indexOf(
diff --git a/src/frontend/tests/pages/pui_build.spec.ts b/src/frontend/tests/pages/pui_build.spec.ts
index e7c7a3275b..f19999ce1e 100644
--- a/src/frontend/tests/pages/pui_build.spec.ts
+++ b/src/frontend/tests/pages/pui_build.spec.ts
@@ -9,8 +9,40 @@ test('PUI - Pages - Build Order', async ({ page }) => {
// Navigate to the correct build order
await page.getByRole('tab', { name: 'Build', exact: true }).click();
+
+ // We have now loaded the "Build Order" table. Check for some expected texts
+ await page.getByText('On Hold').waitFor();
+ await page.getByText('Pending').first().waitFor();
+
+ // Load a particular build order
+ await page.getByRole('cell', { name: 'BO0017' }).click();
+
+ // This build order should be "on hold"
+ await page.getByText('On Hold').first().waitFor();
+ await page.getByRole('button', { name: 'Issue Order' }).click();
+ await page.getByRole('button', { name: 'Cancel' }).click();
+
+ // Back to the build list
+ await page.getByLabel('breadcrumb-0-build-orders').click();
+
+ // Load a different build order
await page.getByRole('cell', { name: 'BO0011' }).click();
+ // This build order should be "in production"
+ await page.getByText('Production').first().waitFor();
+ await page.getByRole('button', { name: 'Complete Order' }).click();
+ await page.getByText('Accept Unallocated').waitFor();
+ await page.getByRole('button', { name: 'Cancel' }).click();
+
+ // Check for other expected actions
+ await page.getByLabel('action-menu-build-order-').click();
+ await page.getByLabel('action-menu-build-order-actions-edit').waitFor();
+ await page.getByLabel('action-menu-build-order-actions-duplicate').waitFor();
+ await page.getByLabel('action-menu-build-order-actions-hold').waitFor();
+ await page.getByLabel('action-menu-build-order-actions-cancel').click();
+ await page.getByText('Remove Incomplete Outputs').waitFor();
+ await page.getByRole('button', { name: 'Cancel' }).click();
+
// Click on some tabs
await page.getByRole('tab', { name: 'Attachments' }).click();
await page.getByRole('tab', { name: 'Notes' }).click();
diff --git a/src/frontend/tests/pages/pui_orders.spec.ts b/src/frontend/tests/pages/pui_orders.spec.ts
new file mode 100644
index 0000000000..8989096cf4
--- /dev/null
+++ b/src/frontend/tests/pages/pui_orders.spec.ts
@@ -0,0 +1,67 @@
+import { test } from '../baseFixtures.ts';
+import { baseUrl } from '../defaults.ts';
+import { doQuickLogin } from '../login.ts';
+
+test('PUI - Sales Orders', async ({ page }) => {
+ await doQuickLogin(page);
+
+ await page.goto(`${baseUrl}/home`);
+ await page.getByRole('tab', { name: 'Sales' }).click();
+ await page.getByRole('tab', { name: 'Sales Orders' }).click();
+
+ // Check for expected text in the table
+ await page.getByRole('tab', { name: 'Sales Orders' }).waitFor();
+ await page.getByText('In Progress').first().waitFor();
+ await page.getByText('On Hold').first().waitFor();
+
+ // Navigate to a particular sales order
+ await page.getByRole('cell', { name: 'SO0003' }).click();
+
+ // Order is "on hold". We will "issue" it and then place on hold again
+ await page.getByText('Sales Order: SO0003').waitFor();
+ await page.getByText('On Hold').first().waitFor();
+ await page.getByRole('button', { name: 'Issue Order' }).click();
+ await page.getByRole('button', { name: 'Submit' }).click();
+
+ // Order should now be "in progress"
+ await page.getByText('In Progress').first().waitFor();
+ await page.getByRole('button', { name: 'Ship Order' }).waitFor();
+
+ await page.getByLabel('action-menu-order-actions').click();
+
+ await page.getByLabel('action-menu-order-actions-edit').waitFor();
+ await page.getByLabel('action-menu-order-actions-duplicate').waitFor();
+ await page.getByLabel('action-menu-order-actions-cancel').waitFor();
+
+ // Mark the order as "on hold" again
+ await page.getByLabel('action-menu-order-actions-hold').click();
+ await page.getByRole('button', { name: 'Submit' }).click();
+
+ await page.getByText('On Hold').first().waitFor();
+ await page.getByRole('button', { name: 'Issue Order' }).waitFor();
+});
+
+test('PUI - Purchase Orders', async ({ page }) => {
+ await doQuickLogin(page);
+
+ await page.goto(`${baseUrl}/home`);
+ await page.getByRole('tab', { name: 'Purchasing' }).click();
+ await page.getByRole('tab', { name: 'Purchase Orders' }).click();
+
+ // Check for expected values
+ await page.getByRole('cell', { name: 'PO0014' }).waitFor();
+ await page.getByText('Wire-E-Coyote').waitFor();
+ await page.getByText('Cancelled').first().waitFor();
+ await page.getByText('Pending').first().waitFor();
+ await page.getByText('On Hold').first().waitFor();
+
+ // Click through to a particular purchase order
+ await page.getByRole('cell', { name: 'PO0013' }).click();
+
+ await page.getByRole('button', { name: 'Issue Order' }).waitFor();
+
+ // Display QR code
+ await page.getByLabel('action-menu-barcode-actions').click();
+ await page.getByLabel('action-menu-barcode-actions-view').click();
+ await page.getByRole('img', { name: 'QR Code' }).waitFor();
+});