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 %} + +{% elif build.active %} {% 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 %} -{% elif order.is_open %} +{% elif order.status == PurchaseOrderStatus.PLACED %} @@ -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 %} @@ -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 %} @@ -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 ( + + ); +} 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} > -