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