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