2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 11:36:44 +00:00

Adds "ON HOLD" status to order models (#7807)

* Add "ON_HOLD" status code for orders

* Add placeholder buttons for purchase order status change

* Adds hooks for introspecting status code enumerations

* Refactor status codes for import session

- Remove hard-coded values

* Refactor into <PrimaryActionButton />

* Cleanup

* more permission checks

* Add placeholder actions for SalesOrder

* Placeholder actions for ReturnOrder

* Placeholder actions for build order

* Actions for "return order"

* Update actions for return order

- Add "on hold" transition

* Implement transitions for SalesOrder

* Allow control over SalesOrderLineItemTable

* Implement PurchaseOrder actions

* Improve API query lookup efficiency

* UI cleanup

* CUI cleanup

* Build Order Updates

- Implement StateTransitionMixin for BuildOrder model
- Add BuildIssue API endpoint
- Add BuildHold API endpoint
- API query improvements
- PUI actions

* Increase timeout

* Bump API version

* Fix API version

* Fix sales order actions

* Update src/backend/InvenTree/order/serializers.py

Co-authored-by: Matthias Mair <code@mjmair.com>

* Adjust build filters

* PUI updates

* CUI refactoring for purchase orders

* Refactor CUI sales order page

* Refactor for return order

* Refactor CUI build page

* Playwright tests for build order

* Add playwright test for sales orders

* Add playwright test for purchase orders

* js linting

* Refactor return order page

* Add missing functions from previous commit

* Fix for "on order" badge on PartDetail page

* UI tweaks

* Fix unit tests

* Update version check script

* Fix typo

* Enforce integer conversion for BaseEnum class

* Unit test updates

- Includes improvement for equality comparison for enums

* Update documentation

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Oliver 2024-08-07 20:34:54 +10:00 committed by GitHub
parent 25f162f4b2
commit 0e8c2973b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 1284 additions and 222 deletions

View File

@ -22,7 +22,7 @@ REPO = os.getenv('GITHUB_REPOSITORY', 'inventree/inventree')
GITHUB_API_URL = os.getenv('GITHUB_API_URL', 'https://api.github.com') 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.""" """Request information on existing releases via the GitHub API."""
# Check for github token # Check for github token
token = os.getenv('GITHUB_TOKEN', None) token = os.getenv('GITHUB_TOKEN', None)
@ -51,6 +51,9 @@ def get_existing_release_tags():
print(f"Version '{tag}' did not match expected pattern") print(f"Version '{tag}' did not match expected pattern")
continue continue
if not include_prerelease and release['prerelease']:
continue
tags.append([int(x) for x in match.groups()]) tags.append([int(x) for x in match.groups()])
return tags return tags
@ -74,7 +77,7 @@ def check_version_number(version_string, allow_duplicate=False):
version_tuple = [int(x) for x in match.groups()] version_tuple = [int(x) for x in match.groups()]
# Look through the existing releases # 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 # Assume that this is the highest release, unless told otherwise
highest_release = True highest_release = True

View File

@ -66,10 +66,11 @@ Each *Build Order* has an associated *Status* flag, which indicates the state of
| Status | Description | | Status | Description |
| ----------- | ----------- | | ----------- | ----------- |
| `Pending` | Build has been created and build is ready for subpart allocation | | `Pending` | Build order has been created, but is not yet in production |
| `Production` | One or more build outputs have been created for this build | | `Production` | Build order is currently in production |
| `Cancelled` | Build has been cancelled | | `On Hold` | Build order has been placed on hold, but is still active |
| `Completed` | Build has been completed | | `Cancelled` | Build order has been cancelled |
| `Completed` | Build order has been completed |
**Source Code** **Source Code**

View File

@ -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 | | 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 | | 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 | | Complete | The purchase order has been completed, and is now closed |
| Cancelled | The purchase order was cancelled, and is now closed | | Cancelled | The purchase order was cancelled, and is now closed |
| Lost | The purchase order was lost, and is now closed | | Lost | The purchase order was lost, and is now closed |

View File

@ -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 | | Pending | The return order has been created, but not sent to the customer |
| In Progress | The return order has been issued 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 | | Complete | The return order was marked as complete, and is now closed |
| Cancelled | The return order was cancelled, and is now closed | | Cancelled | The return order was cancelled, and is now closed |

View File

@ -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 | | 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 | | 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 | | Shipped | The sales order has been shipped, but is not yet complete |
| Complete | The sales order is fully completed, and is now closed | | Complete | The sales order is fully completed, and is now closed |
| Cancelled | The sales order was cancelled, and is now closed | | Cancelled | The sales order was cancelled, and is now closed |

View File

@ -1,13 +1,19 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
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 v232 - 2024-08-03 : https://github.com/inventree/InvenTree/pull/7793
- Allow ordering of SalesOrderShipment API by 'shipment_date' and 'delivery_date' - Allow ordering of SalesOrderShipment API by 'shipment_date' and 'delivery_date'

View File

@ -470,9 +470,19 @@ class BuildFinish(BuildOrderContextMixin, CreateAPI):
"""API endpoint for marking a build as finished (completed).""" """API endpoint for marking a build as finished (completed)."""
queryset = Build.objects.none() queryset = Build.objects.none()
serializer_class = build.serializers.BuildCompleteSerializer 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): class BuildAutoAllocate(BuildOrderContextMixin, CreateAPI):
"""API endpoint for 'automatically' allocating stock against a build order. """API endpoint for 'automatically' allocating stock against a build order.
@ -484,7 +494,6 @@ class BuildAutoAllocate(BuildOrderContextMixin, CreateAPI):
""" """
queryset = Build.objects.none() queryset = Build.objects.none()
serializer_class = build.serializers.BuildAutoAllocationSerializer serializer_class = build.serializers.BuildAutoAllocationSerializer
@ -500,10 +509,22 @@ class BuildAllocate(BuildOrderContextMixin, CreateAPI):
""" """
queryset = Build.objects.none() queryset = Build.objects.none()
serializer_class = build.serializers.BuildAllocationSerializer 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): class BuildCancel(BuildOrderContextMixin, CreateAPI):
"""API endpoint for cancelling a BuildOrder.""" """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('create-output/', BuildOutputCreate.as_view(), name='api-build-output-create'),
path('delete-outputs/', BuildOutputDelete.as_view(), name='api-build-output-delete'), path('delete-outputs/', BuildOutputDelete.as_view(), name='api-build-output-delete'),
path('scrap-outputs/', BuildOutputScrap.as_view(), name='api-build-output-scrap'), 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('finish/', BuildFinish.as_view(), name='api-build-finish'),
path('cancel/', BuildCancel.as_view(), name='api-build-cancel'), path('cancel/', BuildCancel.as_view(), name='api-build-cancel'),
path('unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'), path('unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),

View File

@ -2,7 +2,6 @@
import decimal import decimal
import logging import logging
import os
from datetime import datetime from datetime import datetime
from django.conf import settings from django.conf import settings
@ -26,6 +25,7 @@ from build.status_codes import BuildStatus, BuildStatusGroups
from stock.status_codes import StockStatus, StockHistoryCode from stock.status_codes import StockStatus, StockHistoryCode
from build.validators import generate_next_build_reference, validate_build_order_reference from build.validators import generate_next_build_reference, validate_build_order_reference
from generic.states import StateTransitionMixin
import InvenTree.fields import InvenTree.fields
import InvenTree.helpers import InvenTree.helpers
@ -56,6 +56,7 @@ class Build(
InvenTree.models.MetadataMixin, InvenTree.models.MetadataMixin,
InvenTree.models.PluginValidationMixin, InvenTree.models.PluginValidationMixin,
InvenTree.models.ReferenceIndexingMixin, InvenTree.models.ReferenceIndexingMixin,
StateTransitionMixin,
MPTTModel): MPTTModel):
"""A Build object organises the creation of new StockItem objects from other existing StockItem objects. """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 - Completed count must meet the required quantity
- Untracked parts must be allocated - Untracked parts must be allocated
""" """
if self.status != BuildStatus.PRODUCTION.value:
return False
if self.incomplete_count > 0: if self.incomplete_count > 0:
return False return False
@ -602,8 +607,18 @@ class Build(
def complete_build(self, user, trim_allocated_stock=False): def complete_build(self, user, trim_allocated_stock=False):
"""Mark this build as complete.""" """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 import build.tasks
trim_allocated_stock = kwargs.pop('trim_allocated_stock', False)
user = kwargs.pop('user', None)
if self.incomplete_count > 0: if self.incomplete_count > 0:
return return
@ -665,6 +680,59 @@ class Build(
target_exclude=[user], 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 @transaction.atomic
def cancel_build(self, user, **kwargs): def cancel_build(self, user, **kwargs):
"""Mark the Build as CANCELLED. """Mark the Build as CANCELLED.
@ -674,8 +742,17 @@ class Build(
- Save the Build object - 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 import build.tasks
user = kwargs.pop('user', None)
remove_allocated_stock = kwargs.get('remove_allocated_stock', False) remove_allocated_stock = kwargs.get('remove_allocated_stock', False)
remove_incomplete_outputs = kwargs.get('remove_incomplete_outputs', False) remove_incomplete_outputs = kwargs.get('remove_incomplete_outputs', False)
@ -1276,7 +1353,7 @@ class Build(
@property @property
def is_complete(self): def is_complete(self):
"""Returns True if the build status is COMPLETE.""" """Returns True if the build status is COMPLETE."""
return self.status == BuildStatus.COMPLETE return self.status == BuildStatus.COMPLETE.value
@transaction.atomic @transaction.atomic
def create_build_line_items(self, prevent_duplicates=True): def create_build_line_items(self, prevent_duplicates=True):

View File

@ -34,6 +34,7 @@ import part.serializers as part_serializers
from users.serializers import OwnerSerializer from users.serializers import OwnerSerializer
from .models import Build, BuildLine, BuildItem from .models import Build, BuildLine, BuildItem
from .status_codes import BuildStatus
class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTreeModelSerializer): 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): class BuildCancelSerializer(serializers.Serializer):
"""DRF serializer class for cancelling an active BuildOrder""" """DRF serializer class for cancelling an active BuildOrder"""
@ -737,6 +765,9 @@ class BuildCompleteSerializer(serializers.Serializer):
"""Perform validation of this serializer prior to saving""" """Perform validation of this serializer prior to saving"""
build = self.context['build'] build = self.context['build']
if build.status != BuildStatus.PRODUCTION.value:
raise ValidationError(_("Build order must be in production state"))
if build.incomplete_count > 0: if build.incomplete_count > 0:
raise ValidationError(_("Build order has incomplete outputs")) raise ValidationError(_("Build order has incomplete outputs"))

View File

@ -10,6 +10,7 @@ class BuildStatus(StatusCode):
PENDING = 10, _('Pending'), 'secondary' # Build is pending / active PENDING = 10, _('Pending'), 'secondary' # Build is pending / active
PRODUCTION = 20, _('Production'), 'primary' # Build is in production 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 CANCELLED = 30, _('Cancelled'), 'danger' # Build was cancelled
COMPLETE = 40, _('Complete'), 'success' # Build is complete COMPLETE = 40, _('Complete'), 'success' # Build is complete
@ -19,5 +20,6 @@ class BuildStatusGroups:
ACTIVE_CODES = [ ACTIVE_CODES = [
BuildStatus.PENDING.value, BuildStatus.PENDING.value,
BuildStatus.ON_HOLD.value,
BuildStatus.PRODUCTION.value, BuildStatus.PRODUCTION.value,
] ]

View File

@ -69,22 +69,30 @@ src="{% static 'img/blank_image.png' %}"
</button> </button>
<ul class='dropdown-menu' role='menu'> <ul class='dropdown-menu' role='menu'>
<li><a class='dropdown-item' href='#' id='build-edit'><span class='fas fa-edit icon-green'></span> {% trans "Edit Build" %}</a></li> <li><a class='dropdown-item' href='#' id='build-edit'><span class='fas fa-edit icon-green'></span> {% trans "Edit Build" %}</a></li>
{% if build.is_active %}
<li><a class='dropdown-item' href='#' id='build-cancel'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel Build" %}</a></li>
{% endif %}
{% if roles.build.add %} {% if roles.build.add %}
<li><a class='dropdown-item' href='#' id='build-duplicate'><span class='fas fa-clone'></span> {% trans "Duplicate Build" %}</a></li> <li><a class='dropdown-item' href='#' id='build-duplicate'><span class='fas fa-clone'></span> {% trans "Duplicate Build" %}</a></li>
{% endif %} {% endif %}
{% if build.can_hold %}
<li><a class='dropdown-item' href='#' id='build-hold'><span class='fas fa-hand-paper icon-yellow'></span> {% trans "Hold Build" %}</a></li>
{% endif %}
{% if build.is_active %}
<li><a class='dropdown-item' href='#' id='build-cancel'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel Build" %}</a></li>
{% endif %}
{% if build.status == BuildStatus.CANCELLED and roles.build.delete %} {% if build.status == BuildStatus.CANCELLED and roles.build.delete %}
<li><a class='dropdown-item' href='#' id='build-delete'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete Build" %}</a> <li><a class='dropdown-item' href='#' id='build-delete'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete Build" %}</a>
{% endif %} {% endif %}
</ul> </ul>
</div> </div>
{% if build.active %} {% if build.can_issue %}
<button id='build-issue' title='{% trans "Isueue Build" %}' class='btn btn-primary'>
<span class='fas fa-paper-plane'></span> {% trans "Issue Build" %}
</button>
{% elif build.active %}
<button id='build-complete' title='{% trans "Complete Build" %}' class='btn btn-success'> <button id='build-complete' title='{% trans "Complete Build" %}' class='btn btn-success'>
<span class='fas fa-check-circle'></span> {% trans "Complete Build" %} <span class='fas fa-check-circle'></span> {% trans "Complete Build" %}
</button> </button>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endblock actions %} {% 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: `
<div class='alert alert-block alert-info'>
{% trans "Issue this Build Order?" %}
</div>
`,
onSuccess: function(response) {
window.location.reload();
}
});
});
$("#build-complete").on('click', function() { $("#build-complete").on('click', function() {
completeBuildOrder({{ build.pk }}); completeBuildOrder({{ build.pk }});
}); });

View File

@ -15,6 +15,7 @@ import common.models
from common.settings import set_global_setting from common.settings import set_global_setting
import build.tasks import build.tasks
from build.models import Build, BuildItem, BuildLine, generate_next_build_reference 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 part.models import Part, BomItem, BomItemSubstitute, PartTestTemplate
from stock.models import StockItem, StockItemTestResult from stock.models import StockItem, StockItemTestResult
from users.models import Owner from users.models import Owner
@ -175,6 +176,7 @@ class BuildTestBase(TestCase):
part=cls.assembly, part=cls.assembly,
quantity=10, quantity=10,
issued_by=get_user_model().objects.get(pk=1), issued_by=get_user_model().objects.get(pk=1),
status=BuildStatus.PENDING,
) )
# Create some BuildLine items we can use later on # Create some BuildLine items we can use later on
@ -321,6 +323,10 @@ class BuildTest(BuildTestBase):
# Build is PENDING # Build is PENDING
self.assertEqual(self.build.status, status.BuildStatus.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 # Build has two build outputs
self.assertEqual(self.build.output_count, 2) self.assertEqual(self.build.output_count, 2)
@ -470,6 +476,11 @@ class BuildTest(BuildTestBase):
def test_overallocation_and_trim(self): def test_overallocation_and_trim(self):
"""Test overallocation of stock and trim function""" """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) # Fully allocate tracked stock (not eligible for trimming)
self.allocate_stock( self.allocate_stock(
self.output_1, 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_1, None)
self.build.complete_build_output(self.output_2, None) self.build.complete_build_output(self.output_2, None)
self.assertTrue(self.build.can_complete) self.assertTrue(self.build.can_complete)
n = StockItem.objects.filter(consumed_by=self.build).count() 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.quantity = 30
self.stock_2_1.save() self.stock_2_1.save()
self.build.issue_build()
# Allocate non-tracked parts # Allocate non-tracked parts
self.allocate_stock( self.allocate_stock(
None, None,

View File

@ -16,11 +16,26 @@ class BaseEnum(enum.IntEnum):
obj._value_ = args[0] obj._value_ = args[0]
return obj 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): def __eq__(self, obj):
"""Override equality operator to allow comparison with int.""" """Override equality operator to allow comparison with int."""
if type(self) is type(obj): if type(obj) is int:
return super().__eq__(obj) return self.value == obj
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): def __ne__(self, obj):
"""Override inequality operator to allow comparison with int.""" """Override inequality operator to allow comparison with int."""

View File

@ -360,6 +360,12 @@ class PurchaseOrderContextMixin:
return context return context
class PurchaseOrderHold(PurchaseOrderContextMixin, CreateAPI):
"""API endpoint to place a PurchaseOrder on hold."""
serializer_class = serializers.PurchaseOrderHoldSerializer
class PurchaseOrderCancel(PurchaseOrderContextMixin, CreateAPI): class PurchaseOrderCancel(PurchaseOrderContextMixin, CreateAPI):
"""API endpoint to 'cancel' a purchase order. """API endpoint to 'cancel' a purchase order.
@ -893,6 +899,12 @@ class SalesOrderContextMixin:
return ctx return ctx
class SalesOrderHold(SalesOrderContextMixin, CreateAPI):
"""API endpoint to place a SalesOrder on hold."""
serializer_class = serializers.SalesOrderHoldSerializer
class SalesOrderCancel(SalesOrderContextMixin, CreateAPI): class SalesOrderCancel(SalesOrderContextMixin, CreateAPI):
"""API endpoint to cancel a SalesOrder.""" """API endpoint to cancel a SalesOrder."""
@ -1198,6 +1210,12 @@ class ReturnOrderCancel(ReturnOrderContextMixin, CreateAPI):
serializer_class = serializers.ReturnOrderCancelSerializer serializer_class = serializers.ReturnOrderCancelSerializer
class ReturnOrderHold(ReturnOrderContextMixin, CreateAPI):
"""API endpoint to hold a ReturnOrder."""
serializer_class = serializers.ReturnOrderHoldSerializer
class ReturnOrderComplete(ReturnOrderContextMixin, CreateAPI): class ReturnOrderComplete(ReturnOrderContextMixin, CreateAPI):
"""API endpoint to complete a ReturnOrder.""" """API endpoint to complete a ReturnOrder."""
@ -1481,6 +1499,7 @@ order_api_urls = [
path( path(
'cancel/', PurchaseOrderCancel.as_view(), name='api-po-cancel' 'cancel/', PurchaseOrderCancel.as_view(), name='api-po-cancel'
), ),
path('hold/', PurchaseOrderHold.as_view(), name='api-po-hold'),
path( path(
'complete/', 'complete/',
PurchaseOrderComplete.as_view(), PurchaseOrderComplete.as_view(),
@ -1610,6 +1629,7 @@ order_api_urls = [
SalesOrderAllocateSerials.as_view(), SalesOrderAllocateSerials.as_view(),
name='api-so-allocate-serials', name='api-so-allocate-serials',
), ),
path('hold/', SalesOrderHold.as_view(), name='api-so-hold'),
path('cancel/', SalesOrderCancel.as_view(), name='api-so-cancel'), path('cancel/', SalesOrderCancel.as_view(), name='api-so-cancel'),
path('issue/', SalesOrderIssue.as_view(), name='api-so-issue'), path('issue/', SalesOrderIssue.as_view(), name='api-so-issue'),
path( path(
@ -1709,6 +1729,7 @@ order_api_urls = [
ReturnOrderCancel.as_view(), ReturnOrderCancel.as_view(),
name='api-return-order-cancel', name='api-return-order-cancel',
), ),
path('hold/', ReturnOrderHold.as_view(), name='api-ro-hold'),
path( path(
'complete/', 'complete/',
ReturnOrderComplete.as_view(), ReturnOrderComplete.as_view(),

View File

@ -609,7 +609,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
Order must be currently PENDING. Order must be currently PENDING.
""" """
if self.is_pending: if self.can_issue:
self.status = PurchaseOrderStatus.PLACED.value self.status = PurchaseOrderStatus.PLACED.value
self.issue_date = InvenTree.helpers.current_date() self.issue_date = InvenTree.helpers.current_date()
self.save() self.save()
@ -642,6 +642,19 @@ class PurchaseOrder(TotalPriceMixin, Order):
trigger_event('purchaseorder.completed', id=self.pk) 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 @transaction.atomic
def place_order(self): def place_order(self):
"""Attempt to transition to PLACED status.""" """Attempt to transition to PLACED status."""
@ -656,6 +669,13 @@ class PurchaseOrder(TotalPriceMixin, Order):
self.status, PurchaseOrderStatus.COMPLETE.value, self, self._action_complete 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 @transaction.atomic
def cancel_order(self): def cancel_order(self):
"""Attempt to transition to CANCELLED status.""" """Attempt to transition to CANCELLED status."""
@ -678,12 +698,9 @@ class PurchaseOrder(TotalPriceMixin, Order):
"""A PurchaseOrder can only be cancelled under the following circumstances. """A PurchaseOrder can only be cancelled under the following circumstances.
- Status is PLACED - Status is PLACED
- Status is PENDING - Status is PENDING (or ON_HOLD)
""" """
return self.status in [ return self.status in PurchaseOrderStatusGroups.OPEN
PurchaseOrderStatus.PLACED.value,
PurchaseOrderStatus.PENDING.value,
]
def _action_cancel(self, *args, **kwargs): def _action_cancel(self, *args, **kwargs):
"""Marks the PurchaseOrder as CANCELLED.""" """Marks the PurchaseOrder as CANCELLED."""
@ -701,6 +718,22 @@ class PurchaseOrder(TotalPriceMixin, Order):
content=InvenTreeNotificationBodies.OrderCanceled, 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 # endregion
def pending_line_items(self): def pending_line_items(self):
@ -1074,15 +1107,39 @@ class SalesOrder(TotalPriceMixin, Order):
"""Deprecated version of 'issue_order'.""" """Deprecated version of 'issue_order'."""
self.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): def _action_place(self, *args, **kwargs):
"""Change this order from 'PENDING' to 'IN_PROGRESS'.""" """Change this order from 'PENDING' to 'IN_PROGRESS'."""
if self.status == SalesOrderStatus.PENDING: if self.can_issue:
self.status = SalesOrderStatus.IN_PROGRESS.value self.status = SalesOrderStatus.IN_PROGRESS.value
self.issue_date = InvenTree.helpers.current_date() self.issue_date = InvenTree.helpers.current_date()
self.save() self.save()
trigger_event('salesorder.issued', id=self.pk) 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): def _action_complete(self, *args, **kwargs):
"""Mark this order as "complete.""" """Mark this order as "complete."""
user = kwargs.pop('user', None) user = kwargs.pop('user', None)
@ -1176,6 +1233,13 @@ class SalesOrder(TotalPriceMixin, Order):
**kwargs, **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 @transaction.atomic
def cancel_order(self): def cancel_order(self):
"""Attempt to transition to CANCELLED status.""" """Attempt to transition to CANCELLED status."""
@ -2133,9 +2197,30 @@ class ReturnOrder(TotalPriceMixin, Order):
"""Return True if this order is fully received.""" """Return True if this order is fully received."""
return not self.lines.filter(received_date=None).exists() 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): def _action_cancel(self, *args, **kwargs):
"""Cancel this ReturnOrder (if not already cancelled).""" """Cancel this ReturnOrder (if not already cancelled)."""
if self.status != ReturnOrderStatus.CANCELLED: if self.can_cancel:
self.status = ReturnOrderStatus.CANCELLED.value self.status = ReturnOrderStatus.CANCELLED.value
self.save() self.save()
@ -2151,7 +2236,7 @@ class ReturnOrder(TotalPriceMixin, Order):
def _action_complete(self, *args, **kwargs): def _action_complete(self, *args, **kwargs):
"""Complete this ReturnOrder (if not already completed).""" """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.status = ReturnOrderStatus.COMPLETE.value
self.complete_date = InvenTree.helpers.current_date() self.complete_date = InvenTree.helpers.current_date()
self.save() self.save()
@ -2162,15 +2247,30 @@ class ReturnOrder(TotalPriceMixin, Order):
"""Deprecated version of 'issue_order.""" """Deprecated version of 'issue_order."""
self.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): def _action_place(self, *args, **kwargs):
"""Issue this ReturnOrder (if currently pending).""" """Issue this ReturnOrder (if currently pending)."""
if self.status == ReturnOrderStatus.PENDING: if self.can_issue:
self.status = ReturnOrderStatus.IN_PROGRESS.value self.status = ReturnOrderStatus.IN_PROGRESS.value
self.issue_date = InvenTree.helpers.current_date() self.issue_date = InvenTree.helpers.current_date()
self.save() self.save()
trigger_event('returnorder.issued', id=self.pk) 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 @transaction.atomic
def issue_order(self): def issue_order(self):
"""Attempt to transition to IN_PROGRESS status.""" """Attempt to transition to IN_PROGRESS status."""

View File

@ -284,14 +284,37 @@ class PurchaseOrderSerializer(
) )
class PurchaseOrderCancelSerializer(serializers.Serializer): class OrderAdjustSerializer(serializers.Serializer):
"""Serializer for cancelling a PurchaseOrder.""" """Generic serializer class for adjusting the status of an order."""
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options.
By default, there are no fields required for this serializer type.
"""
fields = [] 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): def get_context_data(self):
"""Return custom context information about the order.""" """Return custom context information about the order."""
self.order = self.context['order'] self.order = self.context['order']
@ -300,21 +323,19 @@ class PurchaseOrderCancelSerializer(serializers.Serializer):
def save(self): def save(self):
"""Save the serializer to 'cancel' the order.""" """Save the serializer to 'cancel' the order."""
order = self.context['order'] if not self.order.can_cancel:
if not order.can_cancel:
raise ValidationError(_('Order cannot be cancelled')) 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.""" """Serializer for completing a purchase order."""
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
fields = [] fields = ['accept_incomplete']
accept_incomplete = serializers.BooleanField( accept_incomplete = serializers.BooleanField(
label=_('Accept Incomplete'), label=_('Accept Incomplete'),
@ -340,22 +361,15 @@ class PurchaseOrderCompleteSerializer(serializers.Serializer):
def save(self): def save(self):
"""Save the serializer to 'complete' the order.""" """Save the serializer to 'complete' the order."""
order = self.context['order'] self.order.complete_order()
order.complete_order()
class PurchaseOrderIssueSerializer(serializers.Serializer): class PurchaseOrderIssueSerializer(OrderAdjustSerializer):
"""Serializer for issuing (sending) a purchase order.""" """Serializer for issuing (sending) a purchase order."""
class Meta:
"""Metaclass options."""
fields = []
def save(self): def save(self):
"""Save the serializer to 'place' the order.""" """Save the serializer to 'place' the order."""
order = self.context['order'] self.order.place_order()
order.place_order()
@register_importer() @register_importer()
@ -402,7 +416,6 @@ class PurchaseOrderLineItemSerializer(
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Initialization routine for the serializer.""" """Initialization routine for the serializer."""
part_detail = kwargs.pop('part_detail', False) part_detail = kwargs.pop('part_detail', False)
order_detail = kwargs.pop('order_detail', False) order_detail = kwargs.pop('order_detail', False)
super().__init__(*args, **kwargs) 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( queryset = queryset.annotate(
total_price=ExpressionWrapper( total_price=ExpressionWrapper(
F('purchase_price') * F('quantity'), output_field=models.DecimalField() F('purchase_price') * F('quantity'), output_field=models.DecimalField()
@ -489,7 +514,7 @@ class PurchaseOrderLineItemSerializer(
) )
supplier_part_detail = SupplierPartSerializer( 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) purchase_price = InvenTreeMoneySerializer(allow_null=True)
@ -898,18 +923,12 @@ class SalesOrderSerializer(
) )
class SalesOrderIssueSerializer(serializers.Serializer): class SalesOrderIssueSerializer(OrderAdjustSerializer):
"""Serializer for issuing a SalesOrder.""" """Serializer for issuing a SalesOrder."""
class Meta:
"""Metaclass options."""
fields = []
def save(self): def save(self):
"""Save the serializer to 'issue' the order.""" """Save the serializer to 'issue' the order."""
order = self.context['order'] self.order.issue_order()
order.issue_order()
class SalesOrderAllocationSerializer(InvenTreeModelSerializer): class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
@ -1313,9 +1332,14 @@ class SalesOrderShipmentAllocationItemSerializer(serializers.Serializer):
return data return data
class SalesOrderCompleteSerializer(serializers.Serializer): class SalesOrderCompleteSerializer(OrderAdjustSerializer):
"""DRF serializer for manually marking a sales order as complete.""" """DRF serializer for manually marking a sales order as complete."""
class Meta:
"""Serializer metaclass options."""
fields = ['accept_incomplete']
accept_incomplete = serializers.BooleanField( accept_incomplete = serializers.BooleanField(
label=_('Accept Incomplete'), label=_('Accept Incomplete'),
help_text=_('Allow order to be closed with incomplete line items'), help_text=_('Allow order to be closed with incomplete line items'),
@ -1344,10 +1368,7 @@ class SalesOrderCompleteSerializer(serializers.Serializer):
def validate(self, data): def validate(self, data):
"""Custom validation for the serializer.""" """Custom validation for the serializer."""
data = super().validate(data) data = super().validate(data)
self.order.can_complete(
order = self.context['order']
order.can_complete(
raise_error=True, raise_error=True,
allow_incomplete_lines=str2bool(data.get('accept_incomplete', False)), allow_incomplete_lines=str2bool(data.get('accept_incomplete', False)),
) )
@ -1357,17 +1378,24 @@ class SalesOrderCompleteSerializer(serializers.Serializer):
def save(self): def save(self):
"""Save the serializer to complete the SalesOrder.""" """Save the serializer to complete the SalesOrder."""
request = self.context['request'] request = self.context['request']
order = self.context['order']
data = self.validated_data data = self.validated_data
user = getattr(request, 'user', None) user = getattr(request, 'user', None)
order.ship_order( self.order.ship_order(
user, allow_incomplete_lines=str2bool(data.get('accept_incomplete', False)) 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.""" """Serializer for marking a SalesOrder as cancelled."""
def get_context_data(self): def get_context_data(self):
@ -1378,9 +1406,7 @@ class SalesOrderCancelSerializer(serializers.Serializer):
def save(self): def save(self):
"""Save the serializer to cancel the order.""" """Save the serializer to cancel the order."""
order = self.context['order'] self.order.cancel_order()
order.cancel_order()
class SalesOrderSerialAllocationSerializer(serializers.Serializer): 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.""" """Serializer for issuing a ReturnOrder."""
class Meta:
"""Metaclass options."""
fields = []
def save(self): def save(self):
"""Save the serializer to 'issue' the order.""" """Save the serializer to 'issue' the order."""
order = self.context['order'] self.order.issue_order()
order.issue_order()
class ReturnOrderCancelSerializer(serializers.Serializer): class ReturnOrderCancelSerializer(OrderAdjustSerializer):
"""Serializer for cancelling a ReturnOrder.""" """Serializer for cancelling a ReturnOrder."""
class Meta:
"""Metaclass options."""
fields = []
def save(self): def save(self):
"""Save the serializer to 'cancel' the order.""" """Save the serializer to 'cancel' the order."""
order = self.context['order'] self.order.cancel_order()
order.cancel_order()
class ReturnOrderCompleteSerializer(serializers.Serializer): class ReturnOrderCompleteSerializer(OrderAdjustSerializer):
"""Serializer for completing a ReturnOrder.""" """Serializer for completing a ReturnOrder."""
class Meta:
"""Metaclass options."""
fields = []
def save(self): def save(self):
"""Save the serializer to 'complete' the order.""" """Save the serializer to 'complete' the order."""
order = self.context['order'] self.order.complete_order()
order.complete_order()
class ReturnOrderLineItemReceiveSerializer(serializers.Serializer): class ReturnOrderLineItemReceiveSerializer(serializers.Serializer):

View File

@ -11,6 +11,7 @@ class PurchaseOrderStatus(StatusCode):
# Order status codes # Order status codes
PENDING = 10, _('Pending'), 'secondary' # Order is pending (not yet placed) PENDING = 10, _('Pending'), 'secondary' # Order is pending (not yet placed)
PLACED = 20, _('Placed'), 'primary' # Order has been placed with supplier 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 COMPLETE = 30, _('Complete'), 'success' # Order has been completed
CANCELLED = 40, _('Cancelled'), 'danger' # Order was cancelled CANCELLED = 40, _('Cancelled'), 'danger' # Order was cancelled
LOST = 50, _('Lost'), 'warning' # Order was lost LOST = 50, _('Lost'), 'warning' # Order was lost
@ -21,7 +22,11 @@ class PurchaseOrderStatusGroups:
"""Groups for PurchaseOrderStatus codes.""" """Groups for PurchaseOrderStatus codes."""
# Open orders # Open orders
OPEN = [PurchaseOrderStatus.PENDING.value, PurchaseOrderStatus.PLACED.value] OPEN = [
PurchaseOrderStatus.PENDING.value,
PurchaseOrderStatus.ON_HOLD.value,
PurchaseOrderStatus.PLACED.value,
]
# Failed orders # Failed orders
FAILED = [ FAILED = [
@ -41,6 +46,7 @@ class SalesOrderStatus(StatusCode):
'primary', 'primary',
) # Order has been issued, and is in progress ) # Order has been issued, and is in progress
SHIPPED = 20, _('Shipped'), 'success' # Order has been shipped to customer 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 COMPLETE = 30, _('Complete'), 'success' # Order is complete
CANCELLED = 40, _('Cancelled'), 'danger' # Order has been cancelled CANCELLED = 40, _('Cancelled'), 'danger' # Order has been cancelled
LOST = 50, _('Lost'), 'warning' # Order was lost LOST = 50, _('Lost'), 'warning' # Order was lost
@ -51,7 +57,11 @@ class SalesOrderStatusGroups:
"""Groups for SalesOrderStatus codes.""" """Groups for SalesOrderStatus codes."""
# Open orders # Open orders
OPEN = [SalesOrderStatus.PENDING.value, SalesOrderStatus.IN_PROGRESS.value] OPEN = [
SalesOrderStatus.PENDING.value,
SalesOrderStatus.ON_HOLD.value,
SalesOrderStatus.IN_PROGRESS.value,
]
# Completed orders # Completed orders
COMPLETE = [SalesOrderStatus.SHIPPED.value, SalesOrderStatus.COMPLETE.value] COMPLETE = [SalesOrderStatus.SHIPPED.value, SalesOrderStatus.COMPLETE.value]
@ -66,6 +76,8 @@ class ReturnOrderStatus(StatusCode):
# Items have been received, and are being inspected # Items have been received, and are being inspected
IN_PROGRESS = 20, _('In Progress'), 'primary' IN_PROGRESS = 20, _('In Progress'), 'primary'
ON_HOLD = 25, _('On Hold'), 'warning'
COMPLETE = 30, _('Complete'), 'success' COMPLETE = 30, _('Complete'), 'success'
CANCELLED = 40, _('Cancelled'), 'danger' CANCELLED = 40, _('Cancelled'), 'danger'
@ -73,7 +85,11 @@ class ReturnOrderStatus(StatusCode):
class ReturnOrderStatusGroups: class ReturnOrderStatusGroups:
"""Groups for ReturnOrderStatus codes.""" """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): class ReturnOrderLineStatus(StatusCode):

View File

@ -63,23 +63,28 @@
<li><a class='dropdown-item' href='#' id='edit-order'> <li><a class='dropdown-item' href='#' id='edit-order'>
<span class='fas fa-edit icon-green'></span> {% trans "Edit order" %} <span class='fas fa-edit icon-green'></span> {% trans "Edit order" %}
</a></li> </a></li>
{% if order.can_cancel %}
<li><a class='dropdown-item' href='#' id='cancel-order'>
<span class='fas fa-times-circle icon-red'></span> {% trans "Cancel order" %}
</a></li>
{% endif %}
{% if roles.purchase_order.add %} {% if roles.purchase_order.add %}
<li><a class='dropdown-item' href='#' id='duplicate-order'> <li><a class='dropdown-item' href='#' id='duplicate-order'>
<span class='fas fa-clone'></span> {% trans "Duplicate order" %} <span class='fas fa-clone'></span> {% trans "Duplicate order" %}
</a></li> </a></li>
{% endif %} {% endif %}
{% if order.can_hold %}
<li><a class='dropdown-item' href='#' id='hold-order'>
<span class='fas fa-hand-paper icon-yellow'></span> {% trans "Hold order" %}
</a></li>
{% endif %}
{% if order.can_cancel %}
<li><a class='dropdown-item' href='#' id='cancel-order'>
<span class='fas fa-times-circle icon-red'></span> {% trans "Cancel order" %}
</a></li>
{% endif %}
</ul> </ul>
</div> </div>
{% if order.is_pending %} {% if order.can_issue %}
<button type='button' class='btn btn-primary' id='place-order' title='{% trans "Issue Order" %}'> <button type='button' class='btn btn-primary' id='place-order' title='{% trans "Issue Order" %}'>
<span class='fas fa-paper-plane'></span> {% trans "Issue Order" %} <span class='fas fa-paper-plane'></span> {% trans "Issue Order" %}
</button> </button>
{% elif order.is_open %} {% elif order.status == PurchaseOrderStatus.PLACED %}
<button type='button' class='btn btn-success' id='complete-order' title='{% trans "Mark order as complete" %}'> <button type='button' class='btn btn-success' id='complete-order' title='{% trans "Mark order as complete" %}'>
<span class='fas fa-check-circle'></span> {% trans "Complete Order" %} <span class='fas fa-check-circle'></span> {% trans "Complete Order" %}
</button> </button>
@ -238,7 +243,7 @@ src="{% static 'img/blank_image.png' %}"
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
{% if order.status == PurchaseOrderStatus.PENDING %} {% if order.status == PurchaseOrderStatus.PENDING or order.status == PurchaseOrderStatus.ON_HOLD %}
$("#place-order").click(function() { $("#place-order").click(function() {
issuePurchaseOrder( issuePurchaseOrder(
@ -281,6 +286,7 @@ $("#complete-order").click(function() {
); );
}); });
{% if order.can_cancel %}
$("#cancel-order").click(function() { $("#cancel-order").click(function() {
cancelPurchaseOrder( 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 %} {% endif %}

View File

@ -74,11 +74,14 @@ src="{% static 'img/blank_image.png' %}"
</button> </button>
<ul class='dropdown-menu' role='menu'> <ul class='dropdown-menu' role='menu'>
<li><a class='dropdown-item' href='#' id='edit-order'><span class='fas fa-edit icon-green'></span> {% trans "Edit order" %}</a></li> <li><a class='dropdown-item' href='#' id='edit-order'><span class='fas fa-edit icon-green'></span> {% trans "Edit order" %}</a></li>
{% if order.is_open %} {% if order.can_hold %}
<li><a class='dropdown-item' href='#' id='hold-order'><span class='fas fa-hand-paper icon-yellow'></span> {% trans "Hold order" %}</a></li>
{% endif %}
{% if order.can_cancel %}
<li><a class='dropdown-item' href='#' id='cancel-order'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel order" %}</a></li> <li><a class='dropdown-item' href='#' id='cancel-order'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel order" %}</a></li>
{% endif %} {% endif %}
</ul> </ul>
{% if order.status == ReturnOrderStatus.PENDING %} {% if order.can_issue %}
<button type='button' class='btn btn-primary' id='issue-order' title='{% trans "Issue Order" %}'> <button type='button' class='btn btn-primary' id='issue-order' title='{% trans "Issue Order" %}'>
<span class='fas fa-paper-plane'></span> {% trans "Issue Order" %} <span class='fas fa-paper-plane'></span> {% trans "Issue Order" %}
</button> </button>
@ -211,7 +214,7 @@ src="{% static 'img/blank_image.png' %}"
{% if roles.return_order.change %} {% if roles.return_order.change %}
{% if order.status == ReturnOrderStatus.PENDING %} {% if order.can_issue %}
$('#issue-order').click(function() { $('#issue-order').click(function() {
issueReturnOrder({{ order.pk }}, { issueReturnOrder({{ order.pk }}, {
reload: true, reload: true,
@ -234,7 +237,7 @@ $('#edit-order').click(function() {
}); });
}); });
{% if order.is_open %} {% if order.can_cancel %}
$('#cancel-order').click(function() { $('#cancel-order').click(function() {
cancelReturnOrder( cancelReturnOrder(
{{ order.pk }}, {{ order.pk }},
@ -244,6 +247,17 @@ $('#cancel-order').click(function() {
); );
}); });
{% endif %} {% endif %}
{% if order.can_hold %}
$("#hold-order").click(function() {
holdOrder(
'{% url "api-ro-hold" order.pk %}',
{
reload: true,
}
);
});
{% endif %}
{% endif %} {% endif %}
{% if report_enabled %} {% if report_enabled %}

View File

@ -73,13 +73,16 @@ src="{% static 'img/blank_image.png' %}"
</button> </button>
<ul class='dropdown-menu' role='menu'> <ul class='dropdown-menu' role='menu'>
<li><a class='dropdown-item' href='#' id='edit-order'><span class='fas fa-edit icon-green'></span> {% trans "Edit order" %}</a></li> <li><a class='dropdown-item' href='#' id='edit-order'><span class='fas fa-edit icon-green'></span> {% trans "Edit order" %}</a></li>
{% if order.is_open %} {% if order.can_hold %}
<li><a class='dropdown-item' href='#' id='hold-order'><span class='fas fa-hand-paper icon-yellow'></span> {% trans "Hold order" %}</a></li>
{% endif %}
{% if order.can_cancel %}
<li><a class='dropdown-item' href='#' id='cancel-order'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel order" %}</a></li> <li><a class='dropdown-item' href='#' id='cancel-order'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel order" %}</a></li>
{% endif %} {% endif %}
</ul> </ul>
</div> </div>
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
{% if order.is_pending %} {% if order.status == SalesOrderStatus.PENDING or order.status == SalesOrderStatus.ON_HOLD %}
<button type='button' class='btn btn-primary' id='issue-order' title='{% trans "Issue Order" %}'> <button type='button' class='btn btn-primary' id='issue-order' title='{% trans "Issue Order" %}'>
<span class='fas fa-paper-plane'></span> {% trans "Issue Order" %} <span class='fas fa-paper-plane'></span> {% trans "Issue Order" %}
</button> </button>
@ -280,6 +283,7 @@ $('#issue-order').click(function() {
); );
}); });
{% if order.can_cancel %}
$("#cancel-order").click(function() { $("#cancel-order").click(function() {
cancelSalesOrder( 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() { $("#ship-order").click(function() {
shipSalesOrder( shipSalesOrder(

View File

@ -51,8 +51,7 @@ from build import models as BuildModels
from build.status_codes import BuildStatusGroups from build.status_codes import BuildStatusGroups
from common.currency import currency_code_default from common.currency import currency_code_default
from common.icons import validate_icon from common.icons import validate_icon
from common.models import InvenTreeSetting from common.settings import get_global_setting
from common.settings import get_global_setting, set_global_setting
from company.models import SupplierPart from company.models import SupplierPart
from InvenTree import helpers, validators from InvenTree import helpers, validators
from InvenTree.fields import InvenTreeURLField from InvenTree.fields import InvenTreeURLField

View File

@ -8,6 +8,7 @@
exportFormatOptions, exportFormatOptions,
formatCurrency, formatCurrency,
getFormFieldValue, getFormFieldValue,
handleFormSuccess,
inventreeGet, inventreeGet,
inventreeLoad, inventreeLoad,
inventreeSave, inventreeSave,
@ -25,6 +26,7 @@
createExtraLineItem, createExtraLineItem,
editExtraLineItem, editExtraLineItem,
exportOrder, exportOrder,
holdOrder,
issuePurchaseOrder, issuePurchaseOrder,
newPurchaseOrderFromOrderWizard, newPurchaseOrderFromOrderWizard,
newSupplierPartFromOrderWizard, newSupplierPartFromOrderWizard,
@ -38,6 +40,29 @@
*/ */
function holdOrder(url, options={}) {
constructForm(
url,
{
method: 'POST',
title: '{% trans "Hold Order" %}',
confirm: true,
preFormContent: function(opts) {
let html = `
<div class='alert alert-info alert-block'>
{% trans "Are you sure you wish to place this order on hold?" %}
</div>`;
return html;
},
onSuccess: function(response) {
handleFormSuccess(response, options);
}
}
);
}
/* Construct a set of fields for a OrderExtraLine form */ /* Construct a set of fields for a OrderExtraLine form */
function extraLineFields(options={}) { function extraLineFields(options={}) {

View File

@ -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 (
<Tooltip label={tooltip ?? title} position="bottom" hidden={!tooltip}>
<Button
leftSection={icon && <InvenTreeIcon icon={icon} />}
color={color}
radius="sm"
p="xs"
onClick={onClick ?? notYetImplemented}
>
{title}
</Button>
</Tooltip>
);
}

View File

@ -368,6 +368,11 @@ export function ApiForm({
return; return;
} }
// Do not auto-focus on a 'choice' field
if (field.field_type == 'choice') {
return;
}
focusField = fieldName; focusField = fieldName;
}); });
} }
@ -378,7 +383,7 @@ export function ApiForm({
form.setFocus(focusField); form.setFocus(focusField);
setInitialFocus(focusField); setInitialFocus(focusField);
}, [props.focus, fields, form.setFocus, isLoading, initialFocus]); }, [props.focus, form.setFocus, isLoading, initialFocus]);
const submitForm: SubmitHandler<FieldValues> = async (data) => { const submitForm: SubmitHandler<FieldValues> = async (data) => {
setNonFieldErrors([]); setNonFieldErrors([]);

View File

@ -16,10 +16,9 @@ import {
import { IconCheck } from '@tabler/icons-react'; import { IconCheck } from '@tabler/icons-react';
import { ReactNode, useMemo } from 'react'; import { ReactNode, useMemo } from 'react';
import { import { ModelType } from '../../enums/ModelType';
ImportSessionStatus, import { useImportSession } from '../../hooks/UseImportSession';
useImportSession import useStatusCodes from '../../hooks/UseStatusCodes';
} from '../../hooks/UseImportSession';
import { StylishText } from '../items/StylishText'; import { StylishText } from '../items/StylishText';
import ImporterDataSelector from './ImportDataSelector'; import ImporterDataSelector from './ImportDataSelector';
import ImporterColumnSelector from './ImporterColumnSelector'; import ImporterColumnSelector from './ImporterColumnSelector';
@ -62,19 +61,23 @@ export default function ImporterDrawer({
}) { }) {
const session = useImportSession({ sessionId: sessionId }); const session = useImportSession({ sessionId: sessionId });
const importSessionStatus = useStatusCodes({
modelType: ModelType.importsession
});
// Map from import steps to stepper steps // Map from import steps to stepper steps
const currentStep = useMemo(() => { const currentStep = useMemo(() => {
switch (session.status) { switch (session.status) {
default: default:
case ImportSessionStatus.INITIAL: case importSessionStatus.INITIAL:
return 0; return 0;
case ImportSessionStatus.MAPPING: case importSessionStatus.MAPPING:
return 1; return 1;
case ImportSessionStatus.IMPORTING: case importSessionStatus.IMPORTING:
return 2; return 2;
case ImportSessionStatus.PROCESSING: case importSessionStatus.PROCESSING:
return 3; return 3;
case ImportSessionStatus.COMPLETE: case importSessionStatus.COMPLETE:
return 4; return 4;
} }
}, [session.status]); }, [session.status]);
@ -85,15 +88,15 @@ export default function ImporterDrawer({
} }
switch (session.status) { switch (session.status) {
case ImportSessionStatus.INITIAL: case importSessionStatus.INITIAL:
return <Text>Initial : TODO</Text>; return <Text>Initial : TODO</Text>;
case ImportSessionStatus.MAPPING: case importSessionStatus.MAPPING:
return <ImporterColumnSelector session={session} />; return <ImporterColumnSelector session={session} />;
case ImportSessionStatus.IMPORTING: case importSessionStatus.IMPORTING:
return <ImporterImportProgress session={session} />; return <ImporterImportProgress session={session} />;
case ImportSessionStatus.PROCESSING: case importSessionStatus.PROCESSING:
return <ImporterDataSelector session={session} />; return <ImporterDataSelector session={session} />;
case ImportSessionStatus.COMPLETE: case importSessionStatus.COMPLETE:
return ( return (
<Stack gap="xs"> <Stack gap="xs">
<Alert <Alert

View File

@ -3,10 +3,9 @@ import { Center, Container, Loader, Stack, Text } from '@mantine/core';
import { useInterval } from '@mantine/hooks'; import { useInterval } from '@mantine/hooks';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { import { ModelType } from '../../enums/ModelType';
ImportSessionState, import { ImportSessionState } from '../../hooks/UseImportSession';
ImportSessionStatus import useStatusCodes from '../../hooks/UseStatusCodes';
} from '../../hooks/UseImportSession';
import { StylishText } from '../items/StylishText'; import { StylishText } from '../items/StylishText';
export default function ImporterImportProgress({ export default function ImporterImportProgress({
@ -14,11 +13,13 @@ export default function ImporterImportProgress({
}: { }: {
session: ImportSessionState; session: ImportSessionState;
}) { }) {
const importSessionStatus = useStatusCodes({
modelType: ModelType.importsession
});
// Periodically refresh the import session data // Periodically refresh the import session data
const interval = useInterval(() => { const interval = useInterval(() => {
console.log('refreshing:', session.status); if (session.status == importSessionStatus.IMPORTING) {
if (session.status == ImportSessionStatus.IMPORTING) {
session.refreshSession(); session.refreshSession();
} }
}, 1000); }, 1000);

View File

@ -89,7 +89,11 @@ export function ActionDropdown({
{...action.indicator} {...action.indicator}
key={action.name} key={action.name}
> >
<Tooltip label={action.tooltip} hidden={!action.tooltip}> <Tooltip
label={action.tooltip}
hidden={!action.tooltip}
position="left"
>
<Menu.Item <Menu.Item
aria-label={id} aria-label={id}
leftSection={action.icon} leftSection={action.icon}
@ -229,6 +233,24 @@ export function DeleteItemAction({
}; };
} }
export function HoldItemAction({
hidden = false,
tooltip,
onClick
}: {
hidden?: boolean;
tooltip?: string;
onClick?: () => void;
}): ActionDropdownItem {
return {
icon: <InvenTreeIcon icon="hold" iconProps={{ color: 'orange' }} />,
name: t`Hold`,
tooltip: tooltip ?? t`Hold`,
onClick: onClick,
hidden: hidden
};
}
export function CancelItemAction({ export function CancelItemAction({
hidden = false, hidden = false,
tooltip, tooltip,

View File

@ -10,7 +10,13 @@ export function RenderOwner({
instance && ( instance && (
<RenderInlineModel <RenderInlineModel
primary={instance.name} primary={instance.name}
suffix={instance.label == 'group' ? <IconUsersGroup /> : <IconUser />} suffix={
instance.label == 'group' ? (
<IconUsersGroup size={16} />
) : (
<IconUser size={16} />
)
}
/> />
) )
); );

View File

@ -62,9 +62,12 @@ export enum ApiEndpoints {
// Build API endpoints // Build API endpoints
build_order_list = 'build/', build_order_list = 'build/',
build_order_issue = 'build/:id/issue/',
build_order_cancel = 'build/:id/cancel/', 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_complete = 'build/:id/complete/',
build_output_create = 'build/:id/create-output/',
build_output_scrap = 'build/:id/scrap-outputs/', build_output_scrap = 'build/:id/scrap-outputs/',
build_output_delete = 'build/:id/delete-outputs/', build_output_delete = 'build/:id/delete-outputs/',
build_line_list = 'build/line/', build_line_list = 'build/line/',
@ -124,14 +127,27 @@ export enum ApiEndpoints {
// Order API endpoints // Order API endpoints
purchase_order_list = 'order/po/', 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_line_list = 'order/po-line/',
purchase_order_receive = 'order/po/:id/receive/', purchase_order_receive = 'order/po/:id/receive/',
sales_order_list = 'order/so/', 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_line_list = 'order/so-line/',
sales_order_shipment_list = 'order/so/shipment/', sales_order_shipment_list = 'order/so/shipment/',
return_order_list = 'order/ro/', 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/', return_order_line_list = 'order/ro-line/',
// Template API endpoints // Template API endpoints

View File

@ -21,6 +21,7 @@ import { InvenTreeIcon } from '../functions/icons';
import { useCreateApiFormModal } from '../hooks/UseForm'; import { useCreateApiFormModal } from '../hooks/UseForm';
import { useBatchCodeGenerator } from '../hooks/UseGenerator'; import { useBatchCodeGenerator } from '../hooks/UseGenerator';
import { apiUrl } from '../states/ApiState'; import { apiUrl } from '../states/ApiState';
import { useGlobalSettingsState } from '../states/SettingsState';
import { PartColumn, StatusColumn } from '../tables/ColumnRenderers'; import { PartColumn, StatusColumn } from '../tables/ColumnRenderers';
/** /**
@ -43,6 +44,8 @@ export function useBuildOrderFields({
} }
}); });
const globalSettings = useGlobalSettingsState();
return useMemo(() => { return useMemo(() => {
return { return {
reference: {}, reference: {},
@ -50,7 +53,13 @@ export function useBuildOrderFields({
disabled: !create, disabled: !create,
filters: { filters: {
assembly: true, 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) { onValueChange(value: any, record?: any) {
// Adjust the destination location for the build order // 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({ export function useBuildOrderOutputFields({

View File

@ -6,6 +6,7 @@ import {
IconBinaryTree2, IconBinaryTree2,
IconBookmarks, IconBookmarks,
IconBox, IconBox,
IconBrandTelegram,
IconBuilding, IconBuilding,
IconBuildingFactory2, IconBuildingFactory2,
IconBuildingStore, IconBuildingStore,
@ -32,6 +33,7 @@ import {
IconFlagShare, IconFlagShare,
IconGitBranch, IconGitBranch,
IconGridDots, IconGridDots,
IconHandStop,
IconHash, IconHash,
IconHierarchy, IconHierarchy,
IconInfoCircle, IconInfoCircle,
@ -142,6 +144,10 @@ const icons = {
plus: IconCirclePlus, plus: IconCirclePlus,
minus: IconCircleMinus, minus: IconCircleMinus,
cancel: IconCircleX, cancel: IconCircleX,
hold: IconHandStop,
issue: IconBrandTelegram,
complete: IconCircleCheck,
deliver: IconTruckDelivery,
// Part Icons // Part Icons
active: IconCheck, active: IconCheck,

View File

@ -1,28 +1,21 @@
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { ApiEndpoints } from '../enums/ApiEndpoints'; import { ApiEndpoints } from '../enums/ApiEndpoints';
import { ModelType } from '../enums/ModelType';
import { useInstance } from './UseInstance'; import { useInstance } from './UseInstance';
import useStatusCodes from './UseStatusCodes';
/* /*
* Custom hook for managing the state of a data import session * 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 = { export type ImportSessionState = {
sessionId: number; sessionId: number;
sessionData: any; sessionData: any;
setSessionData: (data: any) => void; setSessionData: (data: any) => void;
refreshSession: () => void; refreshSession: () => void;
sessionQuery: any; sessionQuery: any;
status: ImportSessionStatus; status: number;
availableFields: Record<string, any>; availableFields: Record<string, any>;
availableColumns: string[]; availableColumns: string[];
mappedFields: any[]; mappedFields: any[];
@ -52,15 +45,17 @@ export function useImportSession({
}); });
const setSessionData = useCallback((data: any) => { const setSessionData = useCallback((data: any) => {
console.log('setting session data:');
console.log(data);
setInstance(data); setInstance(data);
}, []); }, []);
const importSessionStatus = useStatusCodes({
modelType: ModelType.importsession
});
// Current step of the import process // Current step of the import process
const status: ImportSessionStatus = useMemo(() => { const status: number = useMemo(() => {
return sessionData?.status ?? ImportSessionStatus.INITIAL; return sessionData?.status ?? importSessionStatus.INITIAL;
}, [sessionData]); }, [sessionData, importSessionStatus]);
// List of available writeable database field definitions // List of available writeable database field definitions
const availableFields: any[] = useMemo(() => { const availableFields: any[] = useMemo(() => {

View File

@ -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<any, any> = {};
for (let name in statusCodes) {
codesMap[name] = statusCodes[name].key;
}
return codesMap;
}, [modelType, statusCodeList]);
return codes;
}

View File

@ -19,6 +19,7 @@ import { useMemo } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import AdminButton from '../../components/buttons/AdminButton'; import AdminButton from '../../components/buttons/AdminButton';
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
import { PrintingActions } from '../../components/buttons/PrintingActions'; import { PrintingActions } from '../../components/buttons/PrintingActions';
import { DetailsField, DetailsTable } from '../../components/details/Details'; import { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage'; import { DetailsImage } from '../../components/details/DetailsImage';
@ -30,6 +31,7 @@ import {
CancelItemAction, CancelItemAction,
DuplicateItemAction, DuplicateItemAction,
EditItemAction, EditItemAction,
HoldItemAction,
LinkBarcodeAction, LinkBarcodeAction,
UnlinkBarcodeAction, UnlinkBarcodeAction,
ViewBarcodeAction ViewBarcodeAction
@ -47,6 +49,7 @@ import {
useEditApiFormModal useEditApiFormModal
} from '../../hooks/UseForm'; } from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance'; import { useInstance } from '../../hooks/UseInstance';
import useStatusCodes from '../../hooks/UseStatusCodes';
import { apiUrl } from '../../states/ApiState'; import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
import BuildAllocatedStockTable from '../../tables/build/BuildAllocatedStockTable'; import BuildAllocatedStockTable from '../../tables/build/BuildAllocatedStockTable';
@ -364,21 +367,7 @@ export default function BuildDetail() {
pk: build.pk, pk: build.pk,
title: t`Edit Build Order`, title: t`Edit Build Order`,
fields: buildOrderFields, fields: buildOrderFields,
onFormSuccess: () => { onFormSuccess: refreshInstance
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();
}
}); });
const duplicateBuild = useCreateApiFormModal({ const duplicateBuild = useCreateApiFormModal({
@ -393,8 +382,85 @@ export default function BuildDetail() {
modelType: ModelType.build 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 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 [ return [
<PrimaryActionButton
title={t`Issue Order`}
icon="issue"
hidden={!canIssue}
color="blue"
onClick={issueOrder.open}
/>,
<PrimaryActionButton
title={t`Complete Order`}
icon="complete"
hidden={!canComplete}
color="green"
onClick={completeOrder.open}
/>,
<AdminButton model={ModelType.build} pk={build.pk} />, <AdminButton model={ModelType.build} pk={build.pk} />,
<BarcodeActionDropdown <BarcodeActionDropdown
actions={[ actions={[
@ -421,22 +487,28 @@ export default function BuildDetail() {
actions={[ actions={[
EditItemAction({ EditItemAction({
onClick: () => editBuild.open(), onClick: () => editBuild.open(),
hidden: !user.hasChangeRole(UserRoles.build) hidden: !canEdit,
}), tooltip: t`Edit order`
CancelItemAction({
tooltip: t`Cancel order`,
onClick: () => cancelBuild.open(),
hidden: !user.hasChangeRole(UserRoles.build)
// TODO: Hide if build cannot be cancelled
}), }),
DuplicateItemAction({ DuplicateItemAction({
onClick: () => duplicateBuild.open(), onClick: () => duplicateBuild.open(),
tooltip: t`Duplicate order`,
hidden: !user.hasAddRole(UserRoles.build) 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(() => { const buildBadges = useMemo(() => {
return instanceQuery.isFetching return instanceQuery.isFetching
@ -454,7 +526,10 @@ export default function BuildDetail() {
<> <>
{editBuild.modal} {editBuild.modal}
{duplicateBuild.modal} {duplicateBuild.modal}
{cancelBuild.modal} {cancelOrder.modal}
{holdOrder.modal}
{issueOrder.modal}
{completeOrder.modal}
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}> <InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
<Stack gap="xs"> <Stack gap="xs">
<PageDetail <PageDetail

View File

@ -19,9 +19,13 @@ import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import NotesEditor from '../../components/editors/NotesEditor'; import NotesEditor from '../../components/editors/NotesEditor';
import { import {
ActionDropdown, ActionDropdown,
BarcodeActionDropdown,
DeleteItemAction, DeleteItemAction,
DuplicateItemAction, DuplicateItemAction,
EditItemAction EditItemAction,
LinkBarcodeAction,
UnlinkBarcodeAction,
ViewBarcodeAction
} from '../../components/items/ActionDropdown'; } from '../../components/items/ActionDropdown';
import InstanceDetail from '../../components/nav/InstanceDetail'; import InstanceDetail from '../../components/nav/InstanceDetail';
import { PageDetail } from '../../components/nav/PageDetail'; import { PageDetail } from '../../components/nav/PageDetail';
@ -265,6 +269,24 @@ export default function SupplierPartDetail() {
const supplierPartActions = useMemo(() => { const supplierPartActions = useMemo(() => {
return [ return [
<AdminButton model={ModelType.supplierpart} pk={supplierPart.pk} />, <AdminButton model={ModelType.supplierpart} pk={supplierPart.pk} />,
<BarcodeActionDropdown
actions={[
ViewBarcodeAction({
model: ModelType.supplierpart,
pk: supplierPart.pk
}),
LinkBarcodeAction({
hidden:
supplierPart.barcode_hash ||
!user.hasChangeRole(UserRoles.purchase_order)
}),
UnlinkBarcodeAction({
hidden:
!supplierPart.barcode_hash ||
!user.hasChangeRole(UserRoles.purchase_order)
})
]}
/>,
<ActionDropdown <ActionDropdown
tooltip={t`Supplier Part Actions`} tooltip={t`Supplier Part Actions`}
icon={<IconDots />} icon={<IconDots />}

View File

@ -130,6 +130,13 @@ export default function PartDetail() {
icon: 'part', icon: 'part',
copy: true copy: true
}, },
{
type: 'string',
name: 'IPN',
label: t`IPN`,
copy: true,
hidden: !part.IPN
},
{ {
type: 'string', type: 'string',
name: 'description', name: 'description',
@ -177,13 +184,6 @@ export default function PartDetail() {
model: ModelType.stocklocation, model: ModelType.stocklocation,
hidden: part.default_location || !part.category_default_location hidden: part.default_location || !part.category_default_location
}, },
{
type: 'string',
name: 'IPN',
label: t`IPN`,
copy: true,
hidden: !part.IPN
},
{ {
type: 'string', type: 'string',
name: 'units', name: 'units',
@ -799,7 +799,7 @@ export default function PartDetail() {
<DetailsBadge <DetailsBadge
label={t`On Order` + `: ${part.ordering}`} label={t`On Order` + `: ${part.ordering}`}
color="blue" color="blue"
visible={part.on_order > 0} visible={part.ordering > 0}
key="on_order" key="on_order"
/>, />,
<DetailsBadge <DetailsBadge

View File

@ -12,6 +12,7 @@ import { ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import AdminButton from '../../components/buttons/AdminButton'; import AdminButton from '../../components/buttons/AdminButton';
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
import { PrintingActions } from '../../components/buttons/PrintingActions'; import { PrintingActions } from '../../components/buttons/PrintingActions';
import { DetailsField, DetailsTable } from '../../components/details/Details'; import { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage'; import { DetailsImage } from '../../components/details/DetailsImage';
@ -23,6 +24,7 @@ import {
CancelItemAction, CancelItemAction,
DuplicateItemAction, DuplicateItemAction,
EditItemAction, EditItemAction,
HoldItemAction,
LinkBarcodeAction, LinkBarcodeAction,
UnlinkBarcodeAction, UnlinkBarcodeAction,
ViewBarcodeAction ViewBarcodeAction
@ -41,6 +43,8 @@ import {
useEditApiFormModal useEditApiFormModal
} from '../../hooks/UseForm'; } from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance'; import { useInstance } from '../../hooks/UseInstance';
import useStatusCodes from '../../hooks/UseStatusCodes';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
import { AttachmentTable } from '../../tables/general/AttachmentTable'; import { AttachmentTable } from '../../tables/general/AttachmentTable';
import { PurchaseOrderLineItemTable } from '../../tables/purchasing/PurchaseOrderLineItemTable'; import { PurchaseOrderLineItemTable } from '../../tables/purchasing/PurchaseOrderLineItemTable';
@ -287,8 +291,77 @@ export default function PurchaseOrderDetail() {
]; ];
}, [order, id, user]); }, [order, id, user]);
const poStatus = useStatusCodes({ modelType: ModelType.purchaseorder });
const issueOrder = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.purchase_order_issue, order.pk),
title: t`Issue Purchase Order`,
onFormSuccess: refreshInstance,
preFormWarning: t`Issue this order`,
successMessage: t`Order issued`
});
const cancelOrder = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.purchase_order_cancel, order.pk),
title: t`Cancel Purchase Order`,
onFormSuccess: refreshInstance,
preFormWarning: t`Cancel this order`,
successMessage: t`Order cancelled`
});
const holdOrder = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.purchase_order_hold, order.pk),
title: t`Hold Purchase Order`,
onFormSuccess: refreshInstance,
preFormWarning: t`Place this order on hold`,
successMessage: t`Order placed on hold`
});
const completeOrder = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.purchase_order_complete, order.pk),
title: t`Complete Purchase Order`,
successMessage: t`Order completed`,
timeout: 10000,
fields: {
accept_incomplete: {}
},
onFormSuccess: refreshInstance,
preFormWarning: t`Mark this order as complete`
});
const poActions = useMemo(() => { const poActions = useMemo(() => {
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 [ return [
<PrimaryActionButton
title={t`Issue Order`}
icon="issue"
hidden={!canIssue}
color="blue"
onClick={issueOrder.open}
/>,
<PrimaryActionButton
title={t`Complete Order`}
icon="complete"
hidden={!canComplete}
color="green"
onClick={completeOrder.open}
/>,
<AdminButton model={ModelType.purchaseorder} pk={order.pk} />, <AdminButton model={ModelType.purchaseorder} pk={order.pk} />,
<BarcodeActionDropdown <BarcodeActionDropdown
actions={[ actions={[
@ -314,22 +387,31 @@ export default function PurchaseOrderDetail() {
icon={<IconDots />} icon={<IconDots />}
actions={[ actions={[
EditItemAction({ EditItemAction({
hidden: !user.hasChangeRole(UserRoles.purchase_order), hidden: !canEdit,
tooltip: t`Edit order`,
onClick: () => { onClick: () => {
editPurchaseOrder.open(); editPurchaseOrder.open();
} }
}), }),
CancelItemAction({
tooltip: t`Cancel order`
}),
DuplicateItemAction({ DuplicateItemAction({
hidden: !user.hasAddRole(UserRoles.purchase_order), 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(() => { const orderBadges: ReactNode[] = useMemo(() => {
return instanceQuery.isLoading return instanceQuery.isLoading
@ -345,7 +427,12 @@ export default function PurchaseOrderDetail() {
return ( return (
<> <>
{issueOrder.modal}
{holdOrder.modal}
{cancelOrder.modal}
{completeOrder.modal}
{editPurchaseOrder.modal} {editPurchaseOrder.modal}
{duplicatePurchaseOrder.modal}
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}> <InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
<Stack gap="xs"> <Stack gap="xs">
<PageDetail <PageDetail

View File

@ -11,6 +11,7 @@ import { ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import AdminButton from '../../components/buttons/AdminButton'; import AdminButton from '../../components/buttons/AdminButton';
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
import { PrintingActions } from '../../components/buttons/PrintingActions'; import { PrintingActions } from '../../components/buttons/PrintingActions';
import { DetailsField, DetailsTable } from '../../components/details/Details'; import { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage'; import { DetailsImage } from '../../components/details/DetailsImage';
@ -22,6 +23,7 @@ import {
CancelItemAction, CancelItemAction,
DuplicateItemAction, DuplicateItemAction,
EditItemAction, EditItemAction,
HoldItemAction,
LinkBarcodeAction, LinkBarcodeAction,
UnlinkBarcodeAction, UnlinkBarcodeAction,
ViewBarcodeAction ViewBarcodeAction
@ -40,6 +42,8 @@ import {
useEditApiFormModal useEditApiFormModal
} from '../../hooks/UseForm'; } from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance'; import { useInstance } from '../../hooks/UseInstance';
import useStatusCodes from '../../hooks/UseStatusCodes';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
import { AttachmentTable } from '../../tables/general/AttachmentTable'; import { AttachmentTable } from '../../tables/general/AttachmentTable';
import ReturnOrderLineItemTable from '../../tables/sales/ReturnOrderLineItemTable'; import ReturnOrderLineItemTable from '../../tables/sales/ReturnOrderLineItemTable';
@ -101,7 +105,7 @@ export default function ReturnOrderDetail() {
type: 'status', type: 'status',
name: 'status', name: 'status',
label: t`Status`, label: t`Status`,
model: ModelType.salesorder model: ModelType.returnorder
} }
]; ];
@ -120,15 +124,6 @@ export default function ReturnOrderDetail() {
total: order.line_items, total: order.line_items,
progress: order.completed_lines progress: order.completed_lines
}, },
{
type: 'progressbar',
name: 'shipments',
icon: 'shipment',
label: t`Completed Shipments`,
total: order.shipments,
progress: order.completed_shipments
// TODO: Fix this progress bar
},
{ {
type: 'text', type: 'text',
name: 'currency', name: 'currency',
@ -296,8 +291,77 @@ export default function ReturnOrderDetail() {
follow: true follow: true
}); });
const issueOrder = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.return_order_issue, order.pk),
title: t`Issue Return Order`,
onFormSuccess: refreshInstance,
preFormWarning: t`Issue this order`,
successMessage: t`Order issued`
});
const cancelOrder = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.return_order_cancel, order.pk),
title: t`Cancel Return Order`,
onFormSuccess: refreshInstance,
preFormWarning: t`Cancel this order`,
successMessage: t`Order canceled`
});
const holdOrder = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.return_order_hold, order.pk),
title: t`Hold Return Order`,
onFormSuccess: refreshInstance,
preFormWarning: t`Place this order on hold`,
successMessage: t`Order placed on hold`
});
const completeOrder = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.return_order_complete, order.pk),
title: t`Complete Return Order`,
onFormSuccess: refreshInstance,
preFormWarning: t`Mark this order as complete`,
successMessage: t`Order completed`
});
const roStatus = useStatusCodes({ modelType: ModelType.returnorder });
const orderActions = useMemo(() => { const orderActions = useMemo(() => {
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 [ return [
<PrimaryActionButton
title={t`Issue Order`}
icon="issue"
hidden={!canIssue}
color="blue"
onClick={() => issueOrder.open()}
/>,
<PrimaryActionButton
title={t`Complete Order`}
icon="complete"
hidden={!canComplete}
color="green"
onClick={() => completeOrder.open()}
/>,
<AdminButton model={ModelType.returnorder} pk={order.pk} />, <AdminButton model={ModelType.returnorder} pk={order.pk} />,
<BarcodeActionDropdown <BarcodeActionDropdown
actions={[ actions={[
@ -324,25 +388,38 @@ export default function ReturnOrderDetail() {
actions={[ actions={[
EditItemAction({ EditItemAction({
hidden: !user.hasChangeRole(UserRoles.return_order), hidden: !user.hasChangeRole(UserRoles.return_order),
tooltip: t`Edit order`,
onClick: () => { onClick: () => {
editReturnOrder.open(); editReturnOrder.open();
} }
}), }),
CancelItemAction({
tooltip: t`Cancel order`
}),
DuplicateItemAction({ DuplicateItemAction({
tooltip: t`Duplicate order`,
hidden: !user.hasChangeRole(UserRoles.return_order), hidden: !user.hasChangeRole(UserRoles.return_order),
onClick: () => duplicateReturnOrder.open() 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 ( return (
<> <>
{editReturnOrder.modal} {editReturnOrder.modal}
{issueOrder.modal}
{cancelOrder.modal}
{holdOrder.modal}
{completeOrder.modal}
{duplicateReturnOrder.modal} {duplicateReturnOrder.modal}
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}> <InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
<Stack gap="xs"> <Stack gap="xs">

View File

@ -13,6 +13,7 @@ import { ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import AdminButton from '../../components/buttons/AdminButton'; import AdminButton from '../../components/buttons/AdminButton';
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
import { PrintingActions } from '../../components/buttons/PrintingActions'; import { PrintingActions } from '../../components/buttons/PrintingActions';
import { DetailsField, DetailsTable } from '../../components/details/Details'; import { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage'; import { DetailsImage } from '../../components/details/DetailsImage';
@ -24,6 +25,7 @@ import {
CancelItemAction, CancelItemAction,
DuplicateItemAction, DuplicateItemAction,
EditItemAction, EditItemAction,
HoldItemAction,
LinkBarcodeAction, LinkBarcodeAction,
UnlinkBarcodeAction, UnlinkBarcodeAction,
ViewBarcodeAction ViewBarcodeAction
@ -42,6 +44,8 @@ import {
useEditApiFormModal useEditApiFormModal
} from '../../hooks/UseForm'; } from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance'; import { useInstance } from '../../hooks/UseInstance';
import useStatusCodes from '../../hooks/UseStatusCodes';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
import { BuildOrderTable } from '../../tables/build/BuildOrderTable'; import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
import { AttachmentTable } from '../../tables/general/AttachmentTable'; import { AttachmentTable } from '../../tables/general/AttachmentTable';
@ -213,6 +217,8 @@ export default function SalesOrderDetail() {
); );
}, [order, instanceQuery]); }, [order, instanceQuery]);
const soStatus = useStatusCodes({ modelType: ModelType.salesorder });
const salesOrderFields = useSalesOrderFields(); const salesOrderFields = useSalesOrderFields();
const editSalesOrder = useEditApiFormModal({ const editSalesOrder = useEditApiFormModal({
@ -253,6 +259,10 @@ export default function SalesOrderDetail() {
<SalesOrderLineItemTable <SalesOrderLineItemTable
orderId={order.pk} orderId={order.pk}
customerId={order.customer} customerId={order.customer}
editable={
order.status != soStatus.COMPLETE &&
order.status != soStatus.CANCELLED
}
/> />
) )
}, },
@ -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 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 [ return [
<PrimaryActionButton
title={t`Issue Order`}
icon="issue"
hidden={!canIssue}
color="blue"
onClick={issueOrder.open}
/>,
<PrimaryActionButton
title={t`Ship Order`}
icon="deliver"
hidden={!canShip}
color="blue"
onClick={completeOrder.open}
/>,
<PrimaryActionButton
title={t`Complete Order`}
icon="complete"
hidden={!canComplete}
color="green"
onClick={completeOrder.open}
/>,
<AdminButton model={ModelType.salesorder} pk={order.pk} />, <AdminButton model={ModelType.salesorder} pk={order.pk} />,
<BarcodeActionDropdown <BarcodeActionDropdown
actions={[ actions={[
@ -325,20 +411,29 @@ export default function SalesOrderDetail() {
icon={<IconDots />} icon={<IconDots />}
actions={[ actions={[
EditItemAction({ EditItemAction({
hidden: !user.hasChangeRole(UserRoles.sales_order), hidden: !canEdit,
onClick: () => editSalesOrder.open() onClick: () => editSalesOrder.open(),
}), tooltip: t`Edit order`
CancelItemAction({
tooltip: t`Cancel order`
}), }),
DuplicateItemAction({ DuplicateItemAction({
hidden: !user.hasAddRole(UserRoles.sales_order), 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(() => { const orderBadges: ReactNode[] = useMemo(() => {
return instanceQuery.isLoading return instanceQuery.isLoading
@ -355,7 +450,12 @@ export default function SalesOrderDetail() {
return ( return (
<> <>
{issueOrder.modal}
{cancelOrder.modal}
{holdOrder.modal}
{completeOrder.modal}
{editSalesOrder.modal} {editSalesOrder.modal}
{duplicateSalesOrder.modal}
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}> <InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
<Stack gap="xs"> <Stack gap="xs">
<PageDetail <PageDetail

View File

@ -112,6 +112,7 @@ export function RowActions({
withinPortal={true} withinPortal={true}
label={action.tooltip ?? action.title} label={action.tooltip ?? action.title}
key={action.title} key={action.title}
position="left"
> >
<Menu.Item <Menu.Item
color={action.color} color={action.color}

View File

@ -33,6 +33,11 @@ export function UsedInTable({
title: t`Assembly`, title: t`Assembly`,
render: (record: any) => PartColumn(record.part_detail) render: (record: any) => PartColumn(record.part_detail)
}, },
{
accessor: 'part_detail.IPN',
sortable: false,
title: t`IPN`
},
{ {
accessor: 'sub_part', accessor: 'sub_part',
sortable: true, sortable: true,

View File

@ -34,10 +34,12 @@ import { TableHoverCard } from '../TableHoverCard';
export default function SalesOrderLineItemTable({ export default function SalesOrderLineItemTable({
orderId, orderId,
customerId customerId,
editable
}: { }: {
orderId: number; orderId: number;
customerId: number; customerId: number;
editable: boolean;
}) { }) {
const user = useUserState(); const user = useUserState();
const table = useTable('sales-order-line-item'); const table = useTable('sales-order-line-item');
@ -207,7 +209,7 @@ export default function SalesOrderLineItemTable({
}); });
newLine.open(); newLine.open();
}} }}
hidden={!user.hasAddRole(UserRoles.sales_order)} hidden={!editable || !user.hasAddRole(UserRoles.sales_order)}
/> />
]; ];
}, [user, orderId]); }, [user, orderId]);
@ -218,7 +220,10 @@ export default function SalesOrderLineItemTable({
return [ return [
{ {
hidden: allocated || !user.hasChangeRole(UserRoles.sales_order), hidden:
allocated ||
!editable ||
!user.hasChangeRole(UserRoles.sales_order),
title: t`Allocate stock`, title: t`Allocate stock`,
icon: <IconSquareArrowRight />, icon: <IconSquareArrowRight />,
color: 'green' color: 'green'
@ -242,21 +247,21 @@ export default function SalesOrderLineItemTable({
color: 'blue' color: 'blue'
}, },
RowEditAction({ RowEditAction({
hidden: !user.hasChangeRole(UserRoles.sales_order), hidden: !editable || !user.hasChangeRole(UserRoles.sales_order),
onClick: () => { onClick: () => {
setSelectedLine(record.pk); setSelectedLine(record.pk);
editLine.open(); editLine.open();
} }
}), }),
RowDuplicateAction({ RowDuplicateAction({
hidden: !user.hasAddRole(UserRoles.sales_order), hidden: !editable || !user.hasAddRole(UserRoles.sales_order),
onClick: () => { onClick: () => {
setInitialData(record); setInitialData(record);
newLine.open(); newLine.open();
} }
}), }),
RowDeleteAction({ RowDeleteAction({
hidden: !user.hasDeleteRole(UserRoles.sales_order), hidden: !editable || !user.hasDeleteRole(UserRoles.sales_order),
onClick: () => { onClick: () => {
setSelectedLine(record.pk); setSelectedLine(record.pk);
deleteLine.open(); deleteLine.open();
@ -264,7 +269,7 @@ export default function SalesOrderLineItemTable({
}) })
]; ];
}, },
[user] [user, editable]
); );
return ( return (

View File

@ -19,6 +19,7 @@ import {
LineItemsProgressColumn, LineItemsProgressColumn,
ProjectCodeColumn, ProjectCodeColumn,
ReferenceColumn, ReferenceColumn,
ResponsibleColumn,
ShipmentDateColumn, ShipmentDateColumn,
StatusColumn, StatusColumn,
TargetDateColumn TargetDateColumn
@ -129,6 +130,7 @@ export function SalesOrderTable({
CreationDateColumn({}), CreationDateColumn({}),
TargetDateColumn({}), TargetDateColumn({}),
ShipmentDateColumn({}), ShipmentDateColumn({}),
ResponsibleColumn({}),
{ {
accessor: 'total_price', accessor: 'total_price',
title: t`Total Price`, title: t`Total Price`,

View File

@ -59,6 +59,7 @@ export const test = baseTest.extend({
if ( if (
msg.type() === 'error' && msg.type() === 'error' &&
!msg.text().startsWith('ERR: ') && !msg.text().startsWith('ERR: ') &&
msg.text().indexOf('downloadable font: download failed') < 0 &&
msg msg
.text() .text()
.indexOf( .indexOf(

View File

@ -9,8 +9,40 @@ test('PUI - Pages - Build Order', async ({ page }) => {
// Navigate to the correct build order // Navigate to the correct build order
await page.getByRole('tab', { name: 'Build', exact: true }).click(); 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(); 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 // Click on some tabs
await page.getByRole('tab', { name: 'Attachments' }).click(); await page.getByRole('tab', { name: 'Attachments' }).click();
await page.getByRole('tab', { name: 'Notes' }).click(); await page.getByRole('tab', { name: 'Notes' }).click();

View File

@ -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();
});