mirror of
https://github.com/inventree/InvenTree.git
synced 2026-06-13 12:00:51 +00:00
Transfer Order (#11281)
* initial skel commit for transfer orders * initial transfer order backend model * add some serializers, rename PLACED to ISSUED for TransferOrders * adding from admin console works * simple table list almost working, but we need to add order line items.... * add other cols to table * add Transfer Order from table view * moving towards a detail view * wip: adding detail view * add take from and destination serializer details * add other detail grid items * edit/duplicate transfer order * more action buttons * first crack at adding line items * add to line item * add filters * starting work on row actions * more action buttons for line items * fix copy lines in duplicate * basic allocation works * allocations table actions * allocate serials * allocated serial row expansion * add transferred qty to serializers * move items on complete, show in tracking * change panel to transferred stock upon complete * allow incomplete line items * disable edit allocations when completed * add ref pattern and to settings * add admin to line item inline * add calendar and parametric view * basic transfer order report * add transfer order ruleset * starting allocation buisness logic throughout for TOs * disable accept incomplete logic, which was incorrect, until I fix * fix incomplete allocation option * add transferred col to default report * add transfer order to calendar ics view * chain condition for readability * add transfer order allocations table to stockitem view * don't account TO allocations in availability * add transfer orders table for a part * 'consume' option by doing take_stock * squash migrations * starting to test transfer order * more transfer order tests * add transfer order consume test * wip, more tests * more transfer order tests * had to refresh_from_db * switch "to" to "transfer-order" in url paths * only select non-virtual parts from transfer order * add transfer order docs * deconflict migrations * fix frontend build error * fix validation on transfer order reference pattern * add oath2 scope for transfer order * fix state test to include transfer order state * add barcode_model_type_code for transfer order * bump api version * check view role for transfer order, remove debug/commented out lines * add serialized allocation test * Fix migrations * Frontend fixes * Implement required 'company' attribute * transfer order report context * attempt to fix tests * delete transfer order allocations on cancel * add a few playwright tests, more incoming * more playwright * add source and destination locations to table * deconflict migrations * Fix build issue * attempt to fix flaky transfer order test * duplicate transfer order before running tests * Adjust playwright tests * Fix migration dependency order --------- Co-authored-by: Oliver <oliver.henry.walters@gmail.com> Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 491
|
||||
INVENTREE_API_VERSION = 492
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v492 -> 2026-05-22 : https://github.com/inventree/InvenTree/pull/11281
|
||||
- Add Transfer Order model and associated API endpoint
|
||||
|
||||
v491 -> 2026-05-21 : https://github.com/inventree/InvenTree/pull/11979
|
||||
- Add API serializer for deleting a part category
|
||||
- Add API serializer for deleting a stock location
|
||||
|
||||
@@ -904,6 +904,26 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'TRANSFERORDER_ENABLED': {
|
||||
'name': _('Enable Transfer Orders'),
|
||||
'description': _('Enable transfer order functionality in the user interface'),
|
||||
'validator': bool,
|
||||
'default': False,
|
||||
},
|
||||
'TRANSFERORDER_REFERENCE_PATTERN': {
|
||||
'name': _('Transfer Order Reference Pattern'),
|
||||
'description': _(
|
||||
'Required pattern for generating Transfer Order reference field'
|
||||
),
|
||||
'default': 'TO-{ref:04d}',
|
||||
'validator': order.validators.validate_transfer_order_reference_pattern,
|
||||
},
|
||||
'TRANSFERORDER_REQUIRE_RESPONSIBLE': {
|
||||
'name': _('Require Responsible Owner'),
|
||||
'description': _('A responsible owner must be assigned to each order'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'SALESORDER_BLOCK_INCOMPLETE_ITEM_TESTS': {
|
||||
'name': _('Block Incomplete Item Tests'),
|
||||
'description': _(
|
||||
|
||||
@@ -232,8 +232,8 @@ class ApiTests(InvenTreeAPITestCase):
|
||||
"""Test the API endpoint for listing all status models."""
|
||||
response = self.get(reverse('api-status-all'))
|
||||
|
||||
# 10 built-in state classes, plus the added GeneralState class
|
||||
self.assertEqual(len(response.data), 11)
|
||||
# 11 built-in state classes, plus the added GeneralState class
|
||||
self.assertEqual(len(response.data), 12)
|
||||
|
||||
# Test the BuildStatus model
|
||||
build_status = response.data['BuildStatus']
|
||||
@@ -273,7 +273,7 @@ class ApiTests(InvenTreeAPITestCase):
|
||||
)
|
||||
response = self.get(reverse('api-status-all'))
|
||||
|
||||
self.assertEqual(len(response.data), 11)
|
||||
self.assertEqual(len(response.data), 12)
|
||||
|
||||
stock_status_cstm = response.data['StockStatus']
|
||||
self.assertEqual(stock_status_cstm['status_class'], 'StockStatus')
|
||||
|
||||
@@ -170,3 +170,41 @@ class ReturnOrderLineItemAdmin(admin.ModelAdmin):
|
||||
@admin.register(models.ReturnOrderExtraLine)
|
||||
class ReturnOrdeerExtraLineAdmin(GeneralExtraLineAdmin, admin.ModelAdmin):
|
||||
"""Admin class for the ReturnOrderExtraLine model."""
|
||||
|
||||
|
||||
class TransferOrderLineItemInlineAdmin(admin.StackedInline):
|
||||
"""Inline admin class for the TransferOrderLineItem model."""
|
||||
|
||||
autocomplete_fields = ['part']
|
||||
|
||||
model = models.TransferOrderLineItem
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(models.TransferOrder)
|
||||
class TransferOrderAdmin(admin.ModelAdmin):
|
||||
"""Admin class for the TransferOrder model."""
|
||||
|
||||
exclude = ['reference_int', 'address', 'contact']
|
||||
|
||||
list_display = (
|
||||
'reference',
|
||||
'status',
|
||||
'description',
|
||||
'take_from',
|
||||
'destination',
|
||||
'consume',
|
||||
'creation_date',
|
||||
)
|
||||
|
||||
search_fields = ['reference', 'description']
|
||||
|
||||
inlines = [TransferOrderLineItemInlineAdmin]
|
||||
|
||||
autocomplete_fields = [
|
||||
'created_by',
|
||||
'take_from',
|
||||
'destination',
|
||||
'project_code',
|
||||
'responsible',
|
||||
]
|
||||
|
||||
@@ -56,6 +56,8 @@ from order.status_codes import (
|
||||
ReturnOrderStatus,
|
||||
SalesOrderStatus,
|
||||
SalesOrderStatusGroups,
|
||||
TransferOrderStatus,
|
||||
TransferOrderStatusGroups,
|
||||
)
|
||||
from part.models import Part
|
||||
from users.models import Owner
|
||||
@@ -1766,6 +1768,521 @@ class ReturnOrderExtraLineDetail(RetrieveUpdateDestroyAPI):
|
||||
serializer_class = serializers.ReturnOrderExtraLineSerializer
|
||||
|
||||
|
||||
class TransferOrderFilter(OrderFilter):
|
||||
"""Custom API filters for the TransferOrderList endpoint."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = models.TransferOrder
|
||||
fields = []
|
||||
|
||||
include_variants = rest_filters.BooleanFilter(
|
||||
label=_('Include Variants'), method='filter_include_variants'
|
||||
)
|
||||
|
||||
def filter_include_variants(self, queryset, name, value):
|
||||
"""Filter by whether or not to include variants of the selected part.
|
||||
|
||||
Note:
|
||||
- This filter does nothing by itself, and requires the 'part' filter to be set.
|
||||
- Refer to the 'filter_part' method for more information.
|
||||
"""
|
||||
return queryset
|
||||
|
||||
part = rest_filters.ModelChoiceFilter(
|
||||
queryset=Part.objects.all(), field_name='part', method='filter_part'
|
||||
)
|
||||
|
||||
@extend_schema_field(OpenApiTypes.INT)
|
||||
def filter_part(self, queryset, name, part):
|
||||
"""Filter by selected 'part'.
|
||||
|
||||
Note:
|
||||
- If 'include_variants' is set to True, then all variants of the selected part will be included.
|
||||
- Otherwise, just filter by the selected part.
|
||||
"""
|
||||
include_variants = str2bool(self.data.get('include_variants', False))
|
||||
|
||||
if include_variants:
|
||||
parts = part.get_descendants(include_self=True)
|
||||
else:
|
||||
parts = Part.objects.filter(pk=part.pk)
|
||||
|
||||
# Now that we have a queryset of parts, find all the matching return orders
|
||||
line_items = models.TransferOrderLineItem.objects.filter(part__in=parts)
|
||||
|
||||
# Generate a list of ID values for the matching transfer orders
|
||||
transfer_orders = line_items.values_list('order', flat=True).distinct()
|
||||
|
||||
# Now we have a list of matching IDs, filter the queryset
|
||||
return queryset.filter(pk__in=transfer_orders)
|
||||
|
||||
completed_before = InvenTreeDateFilter(
|
||||
label=_('Completed Before'), field_name='complete_date', lookup_expr='lt'
|
||||
)
|
||||
|
||||
completed_after = InvenTreeDateFilter(
|
||||
label=_('Completed After'), field_name='complete_date', lookup_expr='gt'
|
||||
)
|
||||
|
||||
|
||||
class TransferOrderMixin(SerializerContextMixin):
|
||||
"""Mixin class for TransferOrder endpoints."""
|
||||
|
||||
queryset = models.TransferOrder.objects.all()
|
||||
serializer_class = serializers.TransferOrderSerializer
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
"""Return annotated queryset for this endpoint."""
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
queryset = serializers.TransferOrderSerializer.annotate_queryset(queryset)
|
||||
queryset = queryset.prefetch_related('created_by', 'responsible')
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class TransferOrderList(
|
||||
TransferOrderMixin,
|
||||
OrderCreateMixin,
|
||||
DataExportViewMixin,
|
||||
OutputOptionsMixin,
|
||||
ParameterListMixin,
|
||||
ListCreateAPI,
|
||||
):
|
||||
"""API endpoint for accessing a list of TransferOrder objects."""
|
||||
|
||||
filterset_class = TransferOrderFilter
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
# TODO:
|
||||
# output_options = TransferOrderOutputOptions
|
||||
|
||||
ordering_field_aliases = {
|
||||
'reference': ['reference_int', 'reference'],
|
||||
'project_code': ['project_code__code'],
|
||||
}
|
||||
|
||||
ordering_fields = [
|
||||
'creation_date',
|
||||
'created_by',
|
||||
'reference',
|
||||
'line_items',
|
||||
'status',
|
||||
'start_date',
|
||||
'target_date',
|
||||
'complete_date',
|
||||
'project_code',
|
||||
]
|
||||
|
||||
search_fields = ['reference', 'description', 'project_code__code']
|
||||
|
||||
ordering = '-reference'
|
||||
|
||||
|
||||
class TransferOrderDetail(
|
||||
TransferOrderMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI
|
||||
):
|
||||
"""API endpoint for detail view of a single TransferOrder object."""
|
||||
|
||||
# output_options = TransferOrderOutputOptions
|
||||
|
||||
|
||||
class TransferOrderContextMixin:
|
||||
"""Simple mixin class to add a TransferOrder to the serializer context."""
|
||||
|
||||
queryset = models.TransferOrder.objects.all()
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""Add the TransferOrder object to the serializer context."""
|
||||
context = super().get_serializer_context()
|
||||
|
||||
# Pass the Transfer instance through to the serializer for validation
|
||||
try:
|
||||
context['order'] = models.TransferOrder.objects.get(
|
||||
pk=self.kwargs.get('pk', None)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
context['request'] = self.request
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class TransferOrderCancel(TransferOrderContextMixin, CreateAPI):
|
||||
"""API endpoint to cancel a TransferOrder."""
|
||||
|
||||
serializer_class = serializers.TransferOrderCancelSerializer
|
||||
|
||||
|
||||
class TransferOrderHold(TransferOrderContextMixin, CreateAPI):
|
||||
"""API endpoint to hold a TransferOrder."""
|
||||
|
||||
serializer_class = serializers.TransferOrderHoldSerializer
|
||||
|
||||
|
||||
class TransferOrderComplete(TransferOrderContextMixin, CreateAPI):
|
||||
"""API endpoint to complete a TransferOrder."""
|
||||
|
||||
serializer_class = serializers.TransferOrderCompleteSerializer
|
||||
|
||||
|
||||
class TransferOrderIssue(TransferOrderContextMixin, CreateAPI):
|
||||
"""API endpoint to issue a Transfer Order."""
|
||||
|
||||
serializer_class = serializers.TransferOrderIssueSerializer
|
||||
|
||||
|
||||
class TransferOrderAllocateSerials(TransferOrderContextMixin, CreateAPI):
|
||||
"""API endpoint to allocation stock items against a TransferOrder, by specifying serial numbers."""
|
||||
|
||||
queryset = models.TransferOrder.objects.none()
|
||||
serializer_class = serializers.TransferOrderSerialAllocationSerializer
|
||||
|
||||
|
||||
class TransferOrderAllocate(TransferOrderContextMixin, CreateAPI):
|
||||
"""API endpoint to allocate stock items against a TransferOrder.
|
||||
|
||||
- The TransferOrder is specified in the URL
|
||||
- See the TransferOrderAllocationSerializer class
|
||||
"""
|
||||
|
||||
queryset = models.TransferOrder.objects.none()
|
||||
serializer_class = serializers.TransferOrderLineItemAllocationSerializer
|
||||
|
||||
|
||||
class TransferOrderAllocationFilter(FilterSet):
|
||||
"""Custom filterset for the TransferOrderAllocationList endpoint."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = models.TransferOrderAllocation
|
||||
fields = ['line', 'item']
|
||||
|
||||
order = rest_filters.ModelChoiceFilter(
|
||||
queryset=models.TransferOrder.objects.all(),
|
||||
field_name='line__order',
|
||||
label=_('Order'),
|
||||
)
|
||||
|
||||
include_variants = rest_filters.BooleanFilter(
|
||||
label=_('Include Variants'), method='filter_include_variants'
|
||||
)
|
||||
|
||||
def filter_include_variants(self, queryset, name, value):
|
||||
"""Filter by whether or not to include variants of the selected part.
|
||||
|
||||
Note:
|
||||
- This filter does nothing by itself, and requires the 'part' filter to be set.
|
||||
- Refer to the 'filter_part' method for more information.
|
||||
"""
|
||||
return queryset
|
||||
|
||||
part = rest_filters.ModelChoiceFilter(
|
||||
queryset=Part.objects.all(), method='filter_part', label=_('Part')
|
||||
)
|
||||
|
||||
@extend_schema_field(rest_framework.serializers.IntegerField(help_text=_('Part')))
|
||||
def filter_part(self, queryset, name, part):
|
||||
"""Filter by the 'part' attribute.
|
||||
|
||||
Note:
|
||||
- If "include_variants" is True, include all variants of the selected part
|
||||
- Otherwise, just filter by the selected part
|
||||
"""
|
||||
include_variants = str2bool(self.data.get('include_variants', False))
|
||||
|
||||
if include_variants:
|
||||
parts = part.get_descendants(include_self=True)
|
||||
return queryset.filter(item__part__in=parts)
|
||||
else:
|
||||
return queryset.filter(item__part=part)
|
||||
|
||||
outstanding = rest_filters.BooleanFilter(
|
||||
label=_('Outstanding'), method='filter_outstanding'
|
||||
)
|
||||
|
||||
def filter_outstanding(self, queryset, name, value):
|
||||
"""Filter by "outstanding" status (boolean)."""
|
||||
if str2bool(value):
|
||||
return queryset.filter(
|
||||
line__order__status__in=TransferOrderStatusGroups.OPEN
|
||||
# TODO: is there an additional filter here if we aren't using a "shipment"
|
||||
# shipment__shipment_date=None,
|
||||
)
|
||||
return queryset.exclude(
|
||||
# TODO: is there an additional filter here if we aren't using a "shipment"
|
||||
# shipment__shipment_date=None,
|
||||
line__order__status__in=TransferOrderStatusGroups.OPEN
|
||||
)
|
||||
|
||||
location = rest_filters.ModelChoiceFilter(
|
||||
queryset=stock_models.StockLocation.objects.all(),
|
||||
label=_('Location'),
|
||||
method='filter_location',
|
||||
)
|
||||
|
||||
@extend_schema_field(
|
||||
rest_framework.serializers.IntegerField(help_text=_('Location'))
|
||||
)
|
||||
def filter_location(self, queryset, name, location):
|
||||
"""Filter by the location of the allocated StockItem."""
|
||||
locations = location.get_descendants(include_self=True)
|
||||
return queryset.filter(item__location__in=locations)
|
||||
|
||||
|
||||
class TransferOrderAllocationMixin:
|
||||
"""Mixin class for TransferOrderAllocation endpoints."""
|
||||
|
||||
queryset = models.TransferOrderAllocation.objects.all()
|
||||
serializer_class = serializers.TransferOrderAllocationSerializer
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
"""Annotate the queryset for this endpoint."""
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
queryset = queryset.prefetch_related(
|
||||
'item',
|
||||
'item__sales_order',
|
||||
'item__part',
|
||||
'line__part',
|
||||
'item__location',
|
||||
'line__order',
|
||||
'line__order__responsible',
|
||||
'line__order__project_code',
|
||||
'line__order__project_code__responsible',
|
||||
).select_related('line__part__pricing_data', 'item__part__pricing_data')
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class TransferOrderAllocationOutputOptions(OutputConfiguration):
|
||||
"""Output options for the TransferOrderAllocation endpoint."""
|
||||
|
||||
OPTIONS = [
|
||||
InvenTreeOutputOption('part_detail'),
|
||||
InvenTreeOutputOption('item_detail'),
|
||||
InvenTreeOutputOption('order_detail'),
|
||||
InvenTreeOutputOption('location_detail'),
|
||||
]
|
||||
|
||||
|
||||
class TransferOrderAllocationList(
|
||||
TransferOrderAllocationMixin, BulkUpdateMixin, OutputOptionsMixin, ListAPI
|
||||
):
|
||||
"""API endpoint for listing TransferOrderAllocation objects."""
|
||||
|
||||
filterset_class = TransferOrderAllocationFilter
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
output_options = TransferOrderAllocationOutputOptions
|
||||
|
||||
ordering_fields = [
|
||||
'quantity',
|
||||
'part',
|
||||
'serial',
|
||||
'IPN',
|
||||
'batch',
|
||||
'location',
|
||||
'order',
|
||||
]
|
||||
|
||||
ordering_field_aliases = {
|
||||
'IPN': 'item__part__IPN',
|
||||
'part': 'item__part__name',
|
||||
'serial': ['item__serial_int', 'item__serial'],
|
||||
'batch': 'item__batch',
|
||||
'location': 'item__location__name',
|
||||
'order': 'line__order__reference',
|
||||
}
|
||||
|
||||
search_fields = {
|
||||
'item__part__name',
|
||||
'item__part__IPN',
|
||||
'item__serial',
|
||||
'item__batch',
|
||||
}
|
||||
|
||||
|
||||
class TransferOrderAllocationDetail(
|
||||
TransferOrderAllocationMixin, RetrieveUpdateDestroyAPI
|
||||
):
|
||||
"""API endpoint for detail view of a TransferOrderAllocation object."""
|
||||
|
||||
|
||||
class TransferOrderLineItemFilter(LineItemFilter):
|
||||
"""Custom filters for TransferOrderLineItemList endpoint."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = models.TransferOrderLineItem
|
||||
fields = []
|
||||
|
||||
order = rest_filters.ModelChoiceFilter(
|
||||
queryset=models.TransferOrder.objects.all(),
|
||||
field_name='order',
|
||||
label=_('Order'),
|
||||
)
|
||||
|
||||
def filter_include_variants(self, queryset, name, value):
|
||||
"""Filter by whether or not to include variants of the selected part.
|
||||
|
||||
Note:
|
||||
- This filter does nothing by itself, and requires the 'part' filter to be set.
|
||||
- Refer to the 'filter_part' method for more information.
|
||||
"""
|
||||
return queryset
|
||||
|
||||
part = rest_filters.ModelChoiceFilter(
|
||||
queryset=Part.objects.all(),
|
||||
field_name='part',
|
||||
label=_('Part'),
|
||||
method='filter_part',
|
||||
)
|
||||
|
||||
@extend_schema_field(OpenApiTypes.INT)
|
||||
def filter_part(self, queryset, name, part):
|
||||
"""Filter TransferOrderLineItem by selected 'part'.
|
||||
|
||||
Note:
|
||||
- If 'include_variants' is set to True, then all variants of the selected part will be included.
|
||||
- Otherwise, just filter by the selected part.
|
||||
"""
|
||||
include_variants = str2bool(self.data.get('include_variants', False))
|
||||
|
||||
# Construct a queryset of parts to filter by
|
||||
if include_variants:
|
||||
parts = part.get_descendants(include_self=True)
|
||||
else:
|
||||
parts = Part.objects.filter(pk=part.pk)
|
||||
|
||||
return queryset.filter(part__in=parts)
|
||||
|
||||
allocated = rest_filters.BooleanFilter(
|
||||
label=_('Allocated'), method='filter_allocated'
|
||||
)
|
||||
|
||||
def filter_allocated(self, queryset, name, value):
|
||||
"""Filter by lines which are 'allocated'.
|
||||
|
||||
A line is 'allocated' when allocated >= quantity
|
||||
"""
|
||||
q = Q(allocated__gte=F('quantity'))
|
||||
|
||||
if str2bool(value):
|
||||
return queryset.filter(q)
|
||||
return queryset.exclude(q)
|
||||
|
||||
completed = rest_filters.BooleanFilter(
|
||||
label=_('Completed'), method='filter_completed'
|
||||
)
|
||||
|
||||
def filter_completed(self, queryset, name, value):
|
||||
"""Filter by lines which are "completed".
|
||||
|
||||
A line is 'completed' when transferred >= quantity
|
||||
"""
|
||||
q = Q(transferred__gte=F('quantity'))
|
||||
|
||||
if str2bool(value):
|
||||
return queryset.filter(q)
|
||||
return queryset.exclude(q)
|
||||
|
||||
order_complete = rest_filters.BooleanFilter(
|
||||
label=_('Order Complete'), method='filter_order_complete'
|
||||
)
|
||||
|
||||
def filter_order_complete(self, queryset, name, value):
|
||||
"""Filter by whether the order is 'complete' or not."""
|
||||
if str2bool(value):
|
||||
return queryset.filter(order__status__in=TransferOrderStatusGroups.COMPLETE)
|
||||
|
||||
return queryset.exclude(order__status__in=TransferOrderStatusGroups.COMPLETE)
|
||||
|
||||
order_outstanding = rest_filters.BooleanFilter(
|
||||
label=_('Order Outstanding'), method='filter_order_outstanding'
|
||||
)
|
||||
|
||||
def filter_order_outstanding(self, queryset, name, value):
|
||||
"""Filter by whether the order is 'outstanding' or not."""
|
||||
if str2bool(value):
|
||||
return queryset.filter(order__status__in=TransferOrderStatusGroups.OPEN)
|
||||
|
||||
return queryset.exclude(order__status__in=TransferOrderStatusGroups.OPEN)
|
||||
|
||||
|
||||
class TransferOrderLineItemMixin(SerializerContextMixin):
|
||||
"""Mixin class for TransferOrderLineItem endpoints."""
|
||||
|
||||
queryset = models.TransferOrderLineItem.objects.all()
|
||||
serializer_class = serializers.TransferOrderLineItemSerializer
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
"""Return annotated queryset for this endpoint."""
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
queryset = queryset.prefetch_related(
|
||||
'part',
|
||||
'allocations',
|
||||
# 'allocations__transfer',
|
||||
'allocations__item__part',
|
||||
'allocations__item__location',
|
||||
'order',
|
||||
)
|
||||
|
||||
queryset = serializers.TransferOrderLineItemSerializer.annotate_queryset(
|
||||
queryset
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class TransferOrderLineItemOutputOptions(OutputConfiguration):
|
||||
"""Output options for the TransferOrderAllocation endpoint."""
|
||||
|
||||
OPTIONS = [
|
||||
InvenTreeOutputOption('part_detail'),
|
||||
InvenTreeOutputOption('order_detail'),
|
||||
]
|
||||
|
||||
|
||||
class TransferOrderLineItemList(
|
||||
TransferOrderLineItemMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateAPI
|
||||
):
|
||||
"""API endpoint for accessing a list of TransferOrderLineItem objects."""
|
||||
|
||||
filterset_class = TransferOrderLineItemFilter
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
output_options = TransferOrderLineItemOutputOptions
|
||||
|
||||
ordering_fields = [
|
||||
'order',
|
||||
'part',
|
||||
'part__name',
|
||||
'quantity',
|
||||
'allocated',
|
||||
'transferred',
|
||||
'reference',
|
||||
'target_date',
|
||||
]
|
||||
|
||||
ordering_field_aliases = {'part': 'part__name', 'order': 'order__reference'}
|
||||
|
||||
search_fields = ['part__name', 'quantity', 'reference']
|
||||
|
||||
|
||||
class TransferOrderLineItemDetail(
|
||||
TransferOrderLineItemMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI
|
||||
):
|
||||
"""API endpoint for detail view of a TransferOrderLineItem object."""
|
||||
|
||||
output_options = TransferOrderLineItemOutputOptions
|
||||
|
||||
|
||||
class OrderCalendarExport(ICalFeed):
|
||||
"""Calendar export for Purchase/Sales Orders.
|
||||
|
||||
@@ -1844,6 +2361,8 @@ class OrderCalendarExport(ICalFeed):
|
||||
ordertype_title = _('Sales Order')
|
||||
elif obj['ordertype'] == 'return-order':
|
||||
ordertype_title = _('Return Order')
|
||||
elif obj['ordertype'] == 'transfer-order':
|
||||
ordertype_title = _('Transfer Order')
|
||||
else:
|
||||
ordertype_title = _('Unknown')
|
||||
|
||||
@@ -1889,6 +2408,15 @@ class OrderCalendarExport(ICalFeed):
|
||||
).filter(status__lt=ReturnOrderStatus.COMPLETE.value)
|
||||
else:
|
||||
outlist = models.ReturnOrder.objects.filter(target_date__isnull=False)
|
||||
elif obj['ordertype'] == 'transfer-order':
|
||||
if obj['include_completed'] is False:
|
||||
# Do not include completed orders from list in this case
|
||||
# Complete status = 30
|
||||
outlist = models.TransferOrder.objects.filter(
|
||||
target_date__isnull=False
|
||||
).filter(status__lt=TransferOrderStatus.COMPLETE.value)
|
||||
else:
|
||||
outlist = models.TransferOrder.objects.filter(target_date__isnull=False)
|
||||
else:
|
||||
outlist = []
|
||||
|
||||
@@ -1900,7 +2428,12 @@ class OrderCalendarExport(ICalFeed):
|
||||
|
||||
def item_description(self, item):
|
||||
"""Set the event description."""
|
||||
return f'Company: {item.company.name}\nStatus: {item.get_status_display()}\nDescription: {item.description}'
|
||||
if hasattr(item, 'company') and item.company:
|
||||
return f'Company: {item.company.name}\nStatus: {item.get_status_display()}\nDescription: {item.description}'
|
||||
else:
|
||||
return (
|
||||
f'Status: {item.get_status_display()}\nDescription: {item.description}'
|
||||
)
|
||||
|
||||
def item_start_datetime(self, item):
|
||||
"""Set event start to target date. Goal is all-day event."""
|
||||
@@ -2216,9 +2749,97 @@ order_api_urls = [
|
||||
),
|
||||
]),
|
||||
),
|
||||
# API endpoints for transfer orders
|
||||
path(
|
||||
'transfer-order/',
|
||||
include([
|
||||
# Transfer Order detail endpoints
|
||||
path(
|
||||
'<int:pk>/',
|
||||
include([
|
||||
path(
|
||||
'allocate/',
|
||||
TransferOrderAllocate.as_view(),
|
||||
name='api-transfer-order-allocate',
|
||||
),
|
||||
path(
|
||||
'allocate-serials/',
|
||||
TransferOrderAllocateSerials.as_view(),
|
||||
name='api-transfer-order-allocate-serials',
|
||||
),
|
||||
path(
|
||||
'cancel/',
|
||||
TransferOrderCancel.as_view(),
|
||||
name='api-transfer-order-cancel',
|
||||
),
|
||||
path(
|
||||
'hold/',
|
||||
TransferOrderHold.as_view(),
|
||||
name='api-transfer-order-hold',
|
||||
),
|
||||
path(
|
||||
'complete/',
|
||||
TransferOrderComplete.as_view(),
|
||||
name='api-transfer-order-complete',
|
||||
),
|
||||
path(
|
||||
'issue/',
|
||||
TransferOrderIssue.as_view(),
|
||||
name='api-transfer-order-issue',
|
||||
),
|
||||
meta_path(models.TransferOrder),
|
||||
path(
|
||||
'',
|
||||
TransferOrderDetail.as_view(),
|
||||
name='api-transfer-order-detail',
|
||||
),
|
||||
]),
|
||||
),
|
||||
# Transfer Order list
|
||||
path('', TransferOrderList.as_view(), name='api-transfer-order-list'),
|
||||
]),
|
||||
),
|
||||
# API endpoints for transfer order line items
|
||||
path(
|
||||
'transfer-order-line/',
|
||||
include([
|
||||
path(
|
||||
'<int:pk>/',
|
||||
include([
|
||||
meta_path(models.TransferOrderLineItem),
|
||||
path(
|
||||
'',
|
||||
TransferOrderLineItemDetail.as_view(),
|
||||
name='api-transfer-order-line-detail',
|
||||
),
|
||||
]),
|
||||
),
|
||||
path(
|
||||
'',
|
||||
TransferOrderLineItemList.as_view(),
|
||||
name='api-transfer-order-line-list',
|
||||
),
|
||||
]),
|
||||
),
|
||||
# API endpoints for sales order allocations
|
||||
path(
|
||||
'transfer-order-allocation/',
|
||||
include([
|
||||
path(
|
||||
'<int:pk>/',
|
||||
TransferOrderAllocationDetail.as_view(),
|
||||
name='api-transfer-order-allocation-detail',
|
||||
),
|
||||
path(
|
||||
'',
|
||||
TransferOrderAllocationList.as_view(),
|
||||
name='api-transfer-order-allocation-list',
|
||||
),
|
||||
]),
|
||||
),
|
||||
# API endpoint for subscribing to ICS calendar of purchase/sales/return orders
|
||||
re_path(
|
||||
r'^calendar/(?P<ordertype>purchase-order|sales-order|return-order)/calendar.ics',
|
||||
r'^calendar/(?P<ordertype>purchase-order|sales-order|return-order|transfer-order)/calendar.ics',
|
||||
OrderCalendarExport(),
|
||||
name='api-po-so-calendar',
|
||||
),
|
||||
|
||||
@@ -37,3 +37,12 @@ class ReturnOrderEvents(BaseEventEnum):
|
||||
COMPLETED = 'returnorder.completed'
|
||||
CANCELLED = 'returnorder.cancelled'
|
||||
HOLD = 'returnorder.hold'
|
||||
|
||||
|
||||
class TransferOrderEvents(BaseEventEnum):
|
||||
"""Event enumeration for the PurchaseOrder models."""
|
||||
|
||||
ISSUED = 'transferorder.placed'
|
||||
COMPLETED = 'transferorder.completed'
|
||||
CANCELLED = 'transferorder.cancelled'
|
||||
HOLD = 'transferorder.hold'
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
- model: order.transferorder
|
||||
pk: 1
|
||||
fields:
|
||||
reference: 'TO-123'
|
||||
description: "One transfer order, please"
|
||||
status: 10 # Pending
|
||||
|
||||
- model: order.transferorder
|
||||
pk: 2
|
||||
fields:
|
||||
reference: 'TO-124'
|
||||
description: "One transfer order, please"
|
||||
status: 40 # Cancelled
|
||||
|
||||
- model: order.transferorder
|
||||
pk: 3
|
||||
fields:
|
||||
reference: 'TO-125'
|
||||
description: "One transfer order, please"
|
||||
status: 25 # On Hold
|
||||
|
||||
- model: order.transferorder
|
||||
pk: 4
|
||||
fields:
|
||||
reference: 'TO-126'
|
||||
description: "One transfer order, please"
|
||||
status: 20 # Issued
|
||||
|
||||
- model: order.transferorder
|
||||
pk: 5
|
||||
fields:
|
||||
reference: 'TO-127'
|
||||
description: "One transfer order, please"
|
||||
status: 30 # Complete
|
||||
|
||||
|
||||
# Line items for transfer orders
|
||||
- model: order.transferorderlineitem
|
||||
pk: 1
|
||||
fields:
|
||||
order: 5 # the completed order
|
||||
part: 10001 # blue chair
|
||||
quantity: 1
|
||||
|
||||
- model: order.transferorderlineitem
|
||||
pk: 2
|
||||
fields:
|
||||
order: 4 # the issued order
|
||||
part: 10001 # blue chair
|
||||
quantity: 1
|
||||
transferred: 1
|
||||
|
||||
# Allocations for transfer orders
|
||||
# an 'allocated' allocation
|
||||
- model: order.transferorderallocation
|
||||
pk: 1
|
||||
fields:
|
||||
line: 1 # the line item on the completed order
|
||||
item: 1 # stock item
|
||||
quantity: 1
|
||||
|
||||
# a 'complete' allocation
|
||||
- model: order.transferorderallocation
|
||||
pk: 2
|
||||
fields:
|
||||
line: 2 # the line item on the issued order
|
||||
item: 500 # stock item for the blue chair
|
||||
quantity: 1
|
||||
@@ -0,0 +1,482 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-27 22:00
|
||||
|
||||
import InvenTree.fields
|
||||
import InvenTree.models
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import generic.states.fields
|
||||
import generic.states.states
|
||||
import generic.states.transition
|
||||
import generic.states.validators
|
||||
import order.status_codes
|
||||
import order.validators
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("common", "0041_auto_20251203_1244"),
|
||||
("company", "0077_delete_manufacturerpartparameter"),
|
||||
("order", "0117_purchaseorderextraline_line_int_and_more"),
|
||||
("part", "0146_auto_20251203_1241"),
|
||||
("stock", "0116_alter_stockitem_link"),
|
||||
("users", "0005_owner_model"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="TransferOrder",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"metadata",
|
||||
models.JSONField(
|
||||
blank=True,
|
||||
help_text="JSON metadata field, for use by external plugins",
|
||||
null=True,
|
||||
verbose_name="Plugin Metadata",
|
||||
),
|
||||
),
|
||||
("reference_int", models.BigIntegerField(default=0)),
|
||||
(
|
||||
"notes",
|
||||
InvenTree.fields.InvenTreeNotesField(
|
||||
blank=True,
|
||||
help_text="Markdown notes (optional)",
|
||||
max_length=50000,
|
||||
null=True,
|
||||
verbose_name="Notes",
|
||||
),
|
||||
),
|
||||
(
|
||||
"barcode_data",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Third party barcode data",
|
||||
max_length=500,
|
||||
verbose_name="Barcode Data",
|
||||
),
|
||||
),
|
||||
(
|
||||
"barcode_hash",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Unique hash of barcode data",
|
||||
max_length=128,
|
||||
verbose_name="Barcode Hash",
|
||||
),
|
||||
),
|
||||
(
|
||||
"description",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Order description (optional)",
|
||||
max_length=250,
|
||||
verbose_name="Description",
|
||||
),
|
||||
),
|
||||
(
|
||||
"link",
|
||||
InvenTree.fields.InvenTreeURLField(
|
||||
blank=True,
|
||||
help_text="Link to external page",
|
||||
max_length=2000,
|
||||
verbose_name="Link",
|
||||
),
|
||||
),
|
||||
(
|
||||
"start_date",
|
||||
models.DateField(
|
||||
blank=True,
|
||||
help_text="Scheduled start date for this order",
|
||||
null=True,
|
||||
verbose_name="Start date",
|
||||
),
|
||||
),
|
||||
(
|
||||
"target_date",
|
||||
models.DateField(
|
||||
blank=True,
|
||||
help_text="Expected date for order delivery. Order will be overdue after this date.",
|
||||
null=True,
|
||||
verbose_name="Target Date",
|
||||
),
|
||||
),
|
||||
(
|
||||
"creation_date",
|
||||
models.DateField(
|
||||
blank=True, null=True, verbose_name="Creation Date"
|
||||
),
|
||||
),
|
||||
(
|
||||
"issue_date",
|
||||
models.DateField(
|
||||
blank=True,
|
||||
help_text="Date order was issued",
|
||||
null=True,
|
||||
verbose_name="Issue Date",
|
||||
),
|
||||
),
|
||||
(
|
||||
"reference",
|
||||
models.CharField(
|
||||
default=order.validators.generate_next_transfer_order_reference,
|
||||
help_text="Transfer Order Reference",
|
||||
max_length=64,
|
||||
unique=True,
|
||||
validators=[order.validators.validate_transfer_order_reference],
|
||||
verbose_name="Reference",
|
||||
),
|
||||
),
|
||||
(
|
||||
"consume",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text='Rather than transfer the stock to the destination, "consume" it, by removing transferred quantity from the allocated stock item',
|
||||
verbose_name="Consume Stock",
|
||||
),
|
||||
),
|
||||
(
|
||||
"complete_date",
|
||||
models.DateField(
|
||||
blank=True,
|
||||
help_text="Date order was completed",
|
||||
null=True,
|
||||
verbose_name="Completion Date",
|
||||
),
|
||||
),
|
||||
(
|
||||
"status_custom_key",
|
||||
generic.states.fields.ExtraInvenTreeCustomStatusModelField(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="Additional status information for this item",
|
||||
null=True,
|
||||
validators=[
|
||||
generic.states.validators.CustomStatusCodeValidator(
|
||||
status_class=order.status_codes.TransferOrderStatus
|
||||
)
|
||||
],
|
||||
verbose_name="Custom status key",
|
||||
),
|
||||
),
|
||||
(
|
||||
"status",
|
||||
generic.states.fields.InvenTreeCustomStatusModelField(
|
||||
choices=[
|
||||
(10, "Pending"),
|
||||
(20, "Issued"),
|
||||
(25, "On Hold"),
|
||||
(30, "Complete"),
|
||||
(40, "Cancelled"),
|
||||
],
|
||||
default=10,
|
||||
help_text="Transfer order status",
|
||||
validators=[
|
||||
generic.states.validators.CustomStatusCodeValidator(
|
||||
status_class=order.status_codes.TransferOrderStatus
|
||||
)
|
||||
],
|
||||
verbose_name="Status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"address",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Company address for this order",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="+",
|
||||
to="company.address",
|
||||
verbose_name="Address",
|
||||
),
|
||||
),
|
||||
(
|
||||
"contact",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Point of contact for this order",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="+",
|
||||
to="company.contact",
|
||||
verbose_name="Contact",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"destination",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Destination for transferred items",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="incoming_transfers",
|
||||
to="stock.stocklocation",
|
||||
verbose_name="Destination Location",
|
||||
),
|
||||
),
|
||||
(
|
||||
"project_code",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Select project code for this order",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="common.projectcode",
|
||||
verbose_name="Project Code",
|
||||
),
|
||||
),
|
||||
(
|
||||
"responsible",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="User or group responsible for this order",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="+",
|
||||
to="users.owner",
|
||||
verbose_name="Responsible",
|
||||
),
|
||||
),
|
||||
(
|
||||
"take_from",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Source for transferred items",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="sourcing_transfers",
|
||||
to="stock.stocklocation",
|
||||
verbose_name="Source Location",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
help_text="Timestamp of last update",
|
||||
null=True,
|
||||
verbose_name="Updated At",
|
||||
),
|
||||
)
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Transfer Order",
|
||||
},
|
||||
bases=(
|
||||
generic.states.states.StatusCodeMixin,
|
||||
generic.states.transition.StateTransitionMixin,
|
||||
InvenTree.models.InvenTreeAttachmentMixin,
|
||||
InvenTree.models.InvenTreePermissionCheckMixin,
|
||||
InvenTree.models.ContentTypeMixin,
|
||||
InvenTree.models.PluginValidationMixin,
|
||||
models.Model,
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="TransferOrderLineItem",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"metadata",
|
||||
models.JSONField(
|
||||
blank=True,
|
||||
help_text="JSON metadata field, for use by external plugins",
|
||||
null=True,
|
||||
verbose_name="Plugin Metadata",
|
||||
),
|
||||
),
|
||||
(
|
||||
"quantity",
|
||||
InvenTree.fields.RoundingDecimalField(
|
||||
decimal_places=5,
|
||||
default=1,
|
||||
help_text="Item quantity",
|
||||
max_digits=15,
|
||||
validators=[django.core.validators.MinValueValidator(0)],
|
||||
verbose_name="Quantity",
|
||||
),
|
||||
),
|
||||
(
|
||||
"reference",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Line item reference",
|
||||
max_length=100,
|
||||
verbose_name="Reference",
|
||||
),
|
||||
),
|
||||
(
|
||||
"notes",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Line item notes",
|
||||
max_length=500,
|
||||
verbose_name="Notes",
|
||||
),
|
||||
),
|
||||
(
|
||||
"link",
|
||||
InvenTree.fields.InvenTreeURLField(
|
||||
blank=True,
|
||||
help_text="Link to external page",
|
||||
max_length=2000,
|
||||
verbose_name="Link",
|
||||
),
|
||||
),
|
||||
(
|
||||
"target_date",
|
||||
models.DateField(
|
||||
blank=True,
|
||||
help_text="Target date for this line item (leave blank to use the target date from the order)",
|
||||
null=True,
|
||||
verbose_name="Target Date",
|
||||
),
|
||||
),
|
||||
(
|
||||
"order",
|
||||
models.ForeignKey(
|
||||
help_text="Transfer Order",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="lines",
|
||||
to="order.transferorder",
|
||||
verbose_name="Order",
|
||||
),
|
||||
),
|
||||
(
|
||||
"part",
|
||||
models.ForeignKey(
|
||||
help_text="Part",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="transfer_order_line_items",
|
||||
to="part.part",
|
||||
verbose_name="Part",
|
||||
),
|
||||
),
|
||||
(
|
||||
"project_code",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Select project code for this order",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="common.projectcode",
|
||||
verbose_name="Project Code",
|
||||
),
|
||||
),
|
||||
(
|
||||
"transferred",
|
||||
InvenTree.fields.RoundingDecimalField(
|
||||
decimal_places=5,
|
||||
default=0,
|
||||
help_text="transferred quantity",
|
||||
max_digits=15,
|
||||
validators=[django.core.validators.MinValueValidator(0)],
|
||||
verbose_name="transferred",
|
||||
),
|
||||
),
|
||||
(
|
||||
"line",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="Line number for this item (optional)",
|
||||
max_length=20,
|
||||
verbose_name="Line Number",
|
||||
),
|
||||
)
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Transfer Order Line Item",
|
||||
},
|
||||
bases=(
|
||||
InvenTree.models.ContentTypeMixin,
|
||||
InvenTree.models.PluginValidationMixin,
|
||||
models.Model,
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="TransferOrderAllocation",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"quantity",
|
||||
InvenTree.fields.RoundingDecimalField(
|
||||
decimal_places=5,
|
||||
default=1,
|
||||
help_text="Enter stock allocation quantity",
|
||||
max_digits=15,
|
||||
validators=[django.core.validators.MinValueValidator(0)],
|
||||
verbose_name="Quantity",
|
||||
),
|
||||
),
|
||||
(
|
||||
"item",
|
||||
models.ForeignKey(
|
||||
help_text="Select stock item to allocate",
|
||||
limit_choices_to={
|
||||
"belongs_to": None,
|
||||
"part__virtual": False,
|
||||
"sales_order": None,
|
||||
},
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="transfer_order_allocations",
|
||||
to="stock.stockitem",
|
||||
verbose_name="Item",
|
||||
),
|
||||
),
|
||||
(
|
||||
"line",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="allocations",
|
||||
to="order.transferorderlineitem",
|
||||
verbose_name="Line",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Transfer Order Allocation",
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.13 on 2026-05-11 21:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("order", "0118_transferorder"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="transferorderlineitem",
|
||||
name="line_int",
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
]
|
||||
@@ -45,7 +45,12 @@ from InvenTree.fields import (
|
||||
)
|
||||
from InvenTree.helpers import decimal2string, pui_url
|
||||
from InvenTree.helpers_model import notify_responsible
|
||||
from order.events import PurchaseOrderEvents, ReturnOrderEvents, SalesOrderEvents
|
||||
from order.events import (
|
||||
PurchaseOrderEvents,
|
||||
ReturnOrderEvents,
|
||||
SalesOrderEvents,
|
||||
TransferOrderEvents,
|
||||
)
|
||||
from order.status_codes import (
|
||||
PurchaseOrderStatus,
|
||||
PurchaseOrderStatusGroups,
|
||||
@@ -54,6 +59,8 @@ from order.status_codes import (
|
||||
ReturnOrderStatusGroups,
|
||||
SalesOrderStatus,
|
||||
SalesOrderStatusGroups,
|
||||
TransferOrderStatus,
|
||||
TransferOrderStatusGroups,
|
||||
)
|
||||
from part import models as PartModels
|
||||
from plugin.events import trigger_event
|
||||
@@ -265,6 +272,27 @@ class ReturnOrderReportContext(report.mixins.BaseReportContext, TypedDict):
|
||||
customer: Optional[Company]
|
||||
|
||||
|
||||
class TransferOrderReportContext(report.mixins.BaseReportContext, TypedDict):
|
||||
"""Context for the transfer order model.
|
||||
|
||||
Attributes:
|
||||
description: The description field of the TransferOrder
|
||||
reference: The reference field of the TransferOrder
|
||||
title: The title (string representation) of the TransferOrder
|
||||
lines: Query set of all line items associated with the TransferOrder
|
||||
order: The TransferOrder instance itself
|
||||
"""
|
||||
|
||||
description: str
|
||||
reference: str
|
||||
title: str
|
||||
lines: report.mixins.QuerySet['TransferOrderLineItem']
|
||||
order: 'TransferOrder'
|
||||
take_from: 'stock.models.StockLocation'
|
||||
destination: 'stock.models.StockLocation'
|
||||
consume: bool
|
||||
|
||||
|
||||
class Order(
|
||||
StatusCodeMixin,
|
||||
StateTransitionMixin,
|
||||
@@ -374,11 +402,16 @@ class Order(
|
||||
})
|
||||
|
||||
# Check that the referenced 'contact' matches the correct 'company'
|
||||
if self.company and self.contact:
|
||||
if self.contact.company != self.company:
|
||||
raise ValidationError({
|
||||
'contact': _('Contact does not match selected company')
|
||||
})
|
||||
if (
|
||||
hasattr(self, 'company')
|
||||
and hasattr(self, 'contact')
|
||||
and self.company
|
||||
and self.contact
|
||||
and (self.contact.company != self.company)
|
||||
):
|
||||
raise ValidationError({
|
||||
'contact': _('Contact does not match selected company')
|
||||
})
|
||||
|
||||
# Target date should be *after* the start date
|
||||
if self.start_date and self.target_date and self.start_date > self.target_date:
|
||||
@@ -388,11 +421,15 @@ class Order(
|
||||
})
|
||||
|
||||
# Check that the referenced 'address' matches the correct 'company'
|
||||
if self.company and self.address:
|
||||
if self.address.company != self.company:
|
||||
raise ValidationError({
|
||||
'address': _('Address does not match selected company')
|
||||
})
|
||||
if (
|
||||
hasattr(self, 'company')
|
||||
and self.company
|
||||
and self.address
|
||||
and (self.address.company != self.company)
|
||||
):
|
||||
raise ValidationError({
|
||||
'address': _('Address does not match selected company')
|
||||
})
|
||||
|
||||
def clean_line_item(self, line):
|
||||
"""Clean a line item for this order.
|
||||
@@ -408,7 +445,9 @@ class Order(
|
||||
"""Generate context data for the reporting interface."""
|
||||
return {
|
||||
'description': self.description,
|
||||
'extra_lines': self.extra_lines,
|
||||
'extra_lines': getattr(
|
||||
self, 'extra_lines', None
|
||||
), # Transfer Order doesn't have extra lines
|
||||
'lines': self.lines,
|
||||
'order': self,
|
||||
'reference': self.reference,
|
||||
@@ -3155,6 +3194,628 @@ class ReturnOrderExtraLine(OrderExtraLine):
|
||||
)
|
||||
|
||||
|
||||
class TransferOrder(Order):
|
||||
"""A Transfer Order represents a request to transfer stock from one location to another. It provides a place to queue and review changes before execution.
|
||||
|
||||
Attributes:
|
||||
take_from: The stock location to source items from (or null to )
|
||||
destination: The stock location to move items to
|
||||
consume: Rather than move the stock, "consume" it. Helpful if you want to queue up removing stock from inventory
|
||||
"""
|
||||
|
||||
# Global setting for specifying reference pattern
|
||||
REFERENCE_PATTERN_SETTING = 'TRANSFERORDER_REFERENCE_PATTERN'
|
||||
REQUIRE_RESPONSIBLE_SETTING = 'TRANSFERORDER_REQUIRE_RESPONSIBLE'
|
||||
STATUS_CLASS = TransferOrderStatus
|
||||
# UNLOCK_SETTING = 'TRANSFERORDER_EDIT_COMPLETED_ORDERS'
|
||||
|
||||
class Meta:
|
||||
"""Model meta options."""
|
||||
|
||||
verbose_name = _('Transfer Order')
|
||||
|
||||
def report_context(self) -> TransferOrderReportContext:
|
||||
"""Return report context data for this TransferOrder."""
|
||||
return {
|
||||
**super().report_context(),
|
||||
'take_from': self.take_from,
|
||||
'destination': self.destination,
|
||||
'consume': self.consume,
|
||||
}
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
"""Get the 'web' URL for this order."""
|
||||
return pui_url(f'/stock/transfer-order/{self.pk}')
|
||||
|
||||
@staticmethod
|
||||
def get_api_url() -> str:
|
||||
"""Return the API URL associated with the TransferOrder model."""
|
||||
return reverse('api-transfer-order-list')
|
||||
|
||||
@classmethod
|
||||
def get_status_class(cls):
|
||||
"""Return the TransferOrderStatus class."""
|
||||
return TransferOrderStatusGroups
|
||||
|
||||
@classmethod
|
||||
def api_defaults(cls, request=None):
|
||||
"""Return default values for this model when issuing an API OPTIONS request."""
|
||||
defaults = {
|
||||
'reference': order.validators.generate_next_transfer_order_reference()
|
||||
}
|
||||
|
||||
return defaults
|
||||
|
||||
@classmethod
|
||||
def barcode_model_type_code(cls):
|
||||
"""Return the associated barcode model type code for this model."""
|
||||
return 'TO'
|
||||
|
||||
def subscribed_users(self) -> list[User]:
|
||||
"""Return a list of users subscribed to this TransferOrder.
|
||||
|
||||
By this, we mean users to are interested in any of the parts associated with this order.
|
||||
"""
|
||||
subscribed_users = set()
|
||||
|
||||
for line in self.lines.all():
|
||||
if line.part:
|
||||
# Add the part to the list of subscribed users
|
||||
for user in line.part.get_subscribers():
|
||||
subscribed_users.add(user)
|
||||
|
||||
return list(subscribed_users)
|
||||
|
||||
def clean_line_item(self, line):
|
||||
"""Clean a line item for this PurchaseOrder."""
|
||||
super().clean_line_item(line)
|
||||
line.transferred = 0
|
||||
|
||||
def __str__(self):
|
||||
"""Render a string representation of this TransferOrder."""
|
||||
return f'{self.reference} - {self.take_from.name if self.take_from else _("deleted")} --> {self.destination.name if self.destination else _("deleted")}'
|
||||
|
||||
reference = models.CharField(
|
||||
unique=True,
|
||||
max_length=64,
|
||||
blank=False,
|
||||
help_text=_('Transfer Order Reference'),
|
||||
verbose_name=_('Reference'),
|
||||
default=order.validators.generate_next_transfer_order_reference,
|
||||
validators=[order.validators.validate_transfer_order_reference],
|
||||
)
|
||||
|
||||
status = InvenTreeCustomStatusModelField(
|
||||
default=TransferOrderStatus.PENDING.value,
|
||||
choices=TransferOrderStatus.items(),
|
||||
status_class=TransferOrderStatus,
|
||||
verbose_name=_('Status'),
|
||||
help_text=_('Transfer order status'),
|
||||
)
|
||||
|
||||
@property
|
||||
def status_text(self):
|
||||
"""Return the text representation of the status field."""
|
||||
return TransferOrderStatus.text(self.status)
|
||||
|
||||
take_from = models.ForeignKey(
|
||||
'stock.StockLocation',
|
||||
verbose_name=_('Source Location'),
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='sourcing_transfers',
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_('Source for transferred items'),
|
||||
)
|
||||
|
||||
destination = models.ForeignKey(
|
||||
'stock.StockLocation',
|
||||
verbose_name=_('Destination Location'),
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='incoming_transfers',
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_('Destination for transferred items'),
|
||||
)
|
||||
|
||||
consume = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('Consume Stock'),
|
||||
help_text=_(
|
||||
'Rather than transfer the stock to the destination, "consume" it, by removing transferred quantity from the allocated stock item'
|
||||
),
|
||||
)
|
||||
|
||||
complete_date = models.DateField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Completion Date'),
|
||||
help_text=_('Date order was completed'),
|
||||
)
|
||||
|
||||
@property
|
||||
def company(self) -> None:
|
||||
"""Required accessor helper for Order base class."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
"""Return True if the TransferOrder is 'pending'."""
|
||||
return self.status == TransferOrderStatus.PENDING.value
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
"""Return True if the TransferOrder is 'open'."""
|
||||
return self.status in TransferOrderStatusGroups.OPEN
|
||||
|
||||
@property
|
||||
def stock_allocations(self) -> QuerySet:
|
||||
"""Return a queryset containing all allocations for this order."""
|
||||
return TransferOrderAllocation.objects.filter(
|
||||
line__in=[line.pk for line in self.lines.all()]
|
||||
)
|
||||
|
||||
def is_fully_allocated(self) -> bool:
|
||||
"""Return True if all line items are fully allocated."""
|
||||
return all(line.is_fully_allocated() for line in self.lines.all())
|
||||
|
||||
def is_overallocated(self) -> bool:
|
||||
"""Return true if any lines in the order are over-allocated."""
|
||||
return any(line.is_overallocated() for line in self.lines.all())
|
||||
|
||||
def is_completed(self) -> bool:
|
||||
"""Check if this order is "transferred" (all line items transferred)."""
|
||||
return all(line.is_completed() for line in self.lines.all())
|
||||
|
||||
def can_complete(
|
||||
self, raise_error: bool = False, allow_incomplete_lines: bool = False
|
||||
) -> bool:
|
||||
"""Test if this TransferOrder can be completed."""
|
||||
try:
|
||||
if self.status == TransferOrderStatus.COMPLETE.value:
|
||||
raise ValidationError(_('Order is already complete'))
|
||||
|
||||
if self.status == TransferOrderStatus.CANCELLED.value:
|
||||
raise ValidationError(_('Order is already cancelled'))
|
||||
|
||||
if not self.consume and not self.destination:
|
||||
raise ValidationError(
|
||||
_('Order cannot be completed until a destination location is set')
|
||||
)
|
||||
|
||||
if not (self.is_fully_allocated() or allow_incomplete_lines):
|
||||
raise ValidationError(
|
||||
_('Order cannot be completed until it is fully allocated')
|
||||
)
|
||||
except ValidationError as e:
|
||||
if raise_error:
|
||||
raise e
|
||||
else:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@property
|
||||
def can_issue(self) -> bool:
|
||||
"""Return True if this order can be issued."""
|
||||
return self.status in [
|
||||
TransferOrderStatus.PENDING.value,
|
||||
TransferOrderStatus.ON_HOLD.value,
|
||||
]
|
||||
|
||||
@transaction.atomic
|
||||
def issue_order(self):
|
||||
"""Attempt to transition to PLACED status."""
|
||||
return self.handle_transition(
|
||||
self.status, TransferOrderStatus.ISSUED.value, self, self._action_issue
|
||||
)
|
||||
|
||||
# region state changes
|
||||
def _action_issue(self, *args, **kwargs):
|
||||
"""Marks the TransferOrder as ISSUED.
|
||||
|
||||
Order must be currently PENDING.
|
||||
"""
|
||||
if self.can_issue:
|
||||
self.status = TransferOrderStatus.ISSUED.value
|
||||
self.issue_date = InvenTree.helpers.current_date()
|
||||
self.save()
|
||||
|
||||
trigger_event(TransferOrderEvents.ISSUED, id=self.pk)
|
||||
|
||||
# Notify users that the order has been issued
|
||||
notify_responsible(
|
||||
self,
|
||||
TransferOrder,
|
||||
exclude=self.created_by,
|
||||
content=InvenTreeNotificationBodies.NewOrder,
|
||||
extra_users=self.subscribed_users(),
|
||||
)
|
||||
|
||||
@property
|
||||
def can_hold(self) -> bool:
|
||||
"""Return True if this order can be placed on hold."""
|
||||
return self.status in [
|
||||
TransferOrderStatus.PENDING.value,
|
||||
TransferOrderStatus.ISSUED.value,
|
||||
]
|
||||
|
||||
def _action_hold(self, *args, **kwargs):
|
||||
"""Mark this transfer order as 'on hold'."""
|
||||
if self.can_hold:
|
||||
self.status = TransferOrderStatus.ON_HOLD.value
|
||||
self.save()
|
||||
|
||||
trigger_event(TransferOrderEvents.HOLD, id=self.pk)
|
||||
|
||||
@transaction.atomic
|
||||
def _action_complete(self, *args, **kwargs):
|
||||
"""Marks the TransferOrder as COMPLETE.
|
||||
|
||||
Order must be currently ISSUED.
|
||||
"""
|
||||
user = kwargs.pop('user', None)
|
||||
|
||||
if not self.can_complete(raise_error=True, **kwargs):
|
||||
return False
|
||||
|
||||
if self.status == TransferOrderStatus.ISSUED:
|
||||
for allocation in self.allocations():
|
||||
# execute each transfer
|
||||
allocation.complete_allocation(user)
|
||||
|
||||
self.status = TransferOrderStatus.COMPLETE.value
|
||||
self.complete_date = InvenTree.helpers.current_date()
|
||||
|
||||
self.save()
|
||||
|
||||
trigger_event(TransferOrderEvents.COMPLETED, id=self.pk)
|
||||
|
||||
return True
|
||||
|
||||
@transaction.atomic
|
||||
def complete_order(self, user, **kwargs):
|
||||
"""Attempt to transition to COMPLETE status."""
|
||||
return self.handle_transition(
|
||||
self.status,
|
||||
TransferOrderStatus.COMPLETE.value,
|
||||
self,
|
||||
self._action_complete,
|
||||
user=user,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def hold_order(self):
|
||||
"""Attempt to transition to ON_HOLD status."""
|
||||
return self.handle_transition(
|
||||
self.status, TransferOrderStatus.ON_HOLD.value, self, self._action_hold
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def cancel_order(self):
|
||||
"""Attempt to transition to CANCELLED status."""
|
||||
return self.handle_transition(
|
||||
self.status, TransferOrderStatus.CANCELLED.value, self, self._action_cancel
|
||||
)
|
||||
|
||||
@property
|
||||
def can_cancel(self) -> bool:
|
||||
"""A TransferOrder can only be cancelled under the following circumstances.
|
||||
|
||||
- Status is ISSUED
|
||||
- Status is PENDING (or ON_HOLD)
|
||||
"""
|
||||
return self.status in TransferOrderStatusGroups.OPEN
|
||||
|
||||
def _action_cancel(self, *args, **kwargs):
|
||||
"""Cancel this TransferOrder (only if we're allowed to).
|
||||
|
||||
Executes:
|
||||
- Mark the order as 'cancelled'
|
||||
- Delete any StockItems which have been allocated
|
||||
"""
|
||||
if not self.can_cancel:
|
||||
return False
|
||||
|
||||
self.status = TransferOrderStatus.CANCELLED.value
|
||||
self.save()
|
||||
|
||||
# delete allocations
|
||||
for line in self.lines.all():
|
||||
for allocation in line.allocations.all():
|
||||
allocation.delete()
|
||||
|
||||
trigger_event(TransferOrderEvents.CANCELLED, id=self.pk)
|
||||
|
||||
# Notify users that the order has been canceled
|
||||
notify_responsible(
|
||||
self,
|
||||
TransferOrder,
|
||||
exclude=self.created_by,
|
||||
content=InvenTreeNotificationBodies.OrderCanceled,
|
||||
extra_users=self.subscribed_users(),
|
||||
)
|
||||
|
||||
# endregion
|
||||
|
||||
@property
|
||||
def line_count(self) -> int:
|
||||
"""Return the total number of lines associated with this order."""
|
||||
return self.lines.count()
|
||||
|
||||
def completed_line_items(self) -> QuerySet:
|
||||
"""Return a queryset of the completed line items for this order."""
|
||||
return self.lines.filter(transferred__gte=F('quantity'))
|
||||
|
||||
def pending_line_items(self) -> QuerySet:
|
||||
"""Return a queryset of the pending line items for this order."""
|
||||
return self.lines.filter(transferred__lt=F('quantity'))
|
||||
|
||||
@property
|
||||
def completed_line_count(self) -> int:
|
||||
"""Return the number of completed lines for this order."""
|
||||
return self.completed_line_items().count()
|
||||
|
||||
@property
|
||||
def pending_line_count(self) -> int:
|
||||
"""Return the number of pending (incomplete) lines associated with this order."""
|
||||
return self.pending_line_items().count()
|
||||
|
||||
def allocations(self) -> QuerySet:
|
||||
"""Return a queryset of all allocations for this order."""
|
||||
return TransferOrderAllocation.objects.filter(line__order=self)
|
||||
|
||||
|
||||
class TransferOrderLineItem(OrderLineItem):
|
||||
"""Model for a single LineItem in a TransferOrder.
|
||||
|
||||
Attributes:
|
||||
order: Link to the TransferOrder that this line item belongs to
|
||||
part: Link to a Part object (may be null)
|
||||
transferred: The number of items which have actually transferred against this line item
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Model meta options."""
|
||||
|
||||
verbose_name = _('Transfer Order Line Item')
|
||||
|
||||
# Filter for determining if a particular TransferOrderLineItem is overdue
|
||||
OVERDUE_FILTER = (
|
||||
Q(transferred__lt=F('quantity'))
|
||||
& ~Q(target_date=None)
|
||||
& Q(target_date__lt=InvenTree.helpers.current_date())
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with the TransferOrderLineItem model."""
|
||||
return reverse('api-transfer-order-line-list')
|
||||
|
||||
order = models.ForeignKey(
|
||||
TransferOrder,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='lines',
|
||||
verbose_name=_('Order'),
|
||||
help_text=_('Transfer Order'),
|
||||
)
|
||||
|
||||
part = models.ForeignKey(
|
||||
'part.Part',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='transfer_order_line_items',
|
||||
null=True,
|
||||
verbose_name=_('Part'),
|
||||
help_text=_('Part'),
|
||||
# limit_choices_to={'salable': True},
|
||||
)
|
||||
|
||||
transferred = RoundingDecimalField(
|
||||
verbose_name=_('transferred'),
|
||||
help_text=_('transferred quantity'),
|
||||
default=0,
|
||||
max_digits=15,
|
||||
decimal_places=5,
|
||||
validators=[MinValueValidator(0)],
|
||||
)
|
||||
|
||||
def allocated_quantity(self):
|
||||
"""Return the total stock quantity allocated to this LineItem.
|
||||
|
||||
This is a summation of the quantity of each attached StockItem
|
||||
"""
|
||||
if not self.pk:
|
||||
return 0
|
||||
|
||||
query = self.allocations.aggregate(
|
||||
allocated=Coalesce(Sum('quantity'), Decimal(0))
|
||||
)
|
||||
|
||||
return query['allocated']
|
||||
|
||||
def is_fully_allocated(self) -> bool:
|
||||
"""Return True if this line item is fully allocated."""
|
||||
# If the linked part is "virtual", then we cannot allocate stock against it
|
||||
if self.part and self.part.virtual:
|
||||
return True
|
||||
|
||||
return self.allocated_quantity() >= self.quantity
|
||||
|
||||
def is_overallocated(self) -> bool:
|
||||
"""Return True if this line item is over allocated."""
|
||||
return self.allocated_quantity() > self.quantity
|
||||
|
||||
def is_completed(self) -> bool:
|
||||
"""Return True if this line item is completed (has been fully shipped)."""
|
||||
# A "virtual" part is always considered to be "completed"
|
||||
if self.part and self.part.virtual:
|
||||
return True
|
||||
|
||||
return self.transferred >= self.quantity
|
||||
|
||||
|
||||
class TransferOrderAllocation(models.Model):
|
||||
"""This model is used to 'allocate' stock items to a TransferOrder. Items that are "allocated" to a TransferOrder are not yet "attached" to the order, but they will be once the order is fulfilled.
|
||||
|
||||
Attributes:
|
||||
line: TransferOrderLineItem reference
|
||||
item: StockItem reference
|
||||
quantity: Quantity to take from the StockItem
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Model meta options."""
|
||||
|
||||
verbose_name = _('Transfer Order Allocation')
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with the TransferOrderAllocation model."""
|
||||
return reverse('api-transfer-order-allocation-list')
|
||||
|
||||
def clean(self):
|
||||
"""Validate the TransferOrderAllocation object.
|
||||
|
||||
Executes:
|
||||
- Cannot allocate stock to a line item without a part reference
|
||||
- The referenced part must match the part associated with the line item
|
||||
- Allocated quantity cannot exceed the quantity of the stock item
|
||||
- Allocation quantity must be "1" if the StockItem is serialized
|
||||
- Allocation quantity cannot be zero
|
||||
"""
|
||||
super().clean()
|
||||
|
||||
errors = {}
|
||||
|
||||
try:
|
||||
if not self.item:
|
||||
raise ValidationError({'item': _('Stock item has not been assigned')})
|
||||
except stock.models.StockItem.DoesNotExist:
|
||||
raise ValidationError({'item': _('Stock item has not been assigned')})
|
||||
|
||||
try:
|
||||
if self.line.part != self.item.part:
|
||||
variants = self.line.part.get_descendants(include_self=True)
|
||||
if self.line.part not in variants:
|
||||
errors['item'] = _(
|
||||
'Cannot allocate stock item to a line with a different part'
|
||||
)
|
||||
except PartModels.Part.DoesNotExist:
|
||||
errors['line'] = _('Cannot allocate stock to a line without a part')
|
||||
|
||||
if self.quantity > self.item.quantity:
|
||||
errors['quantity'] = _('Allocation quantity cannot exceed stock quantity')
|
||||
|
||||
# Ensure that we do not 'over allocate' a stock item
|
||||
build_allocation_count = self.item.build_allocation_count()
|
||||
sales_allocation_count = self.item.sales_order_allocation_count(
|
||||
exclude_allocations={'pk': self.pk}
|
||||
)
|
||||
|
||||
total_allocation = (
|
||||
build_allocation_count + sales_allocation_count + self.quantity
|
||||
)
|
||||
|
||||
if total_allocation > self.item.quantity:
|
||||
errors['quantity'] = _('Stock item is over-allocated')
|
||||
|
||||
if self.quantity <= 0:
|
||||
errors['quantity'] = _('Allocation quantity must be greater than zero')
|
||||
|
||||
if self.item.serial and self.quantity != 1:
|
||||
errors['quantity'] = _('Quantity must be 1 for serialized stock item')
|
||||
|
||||
if len(errors) > 0:
|
||||
raise ValidationError(errors)
|
||||
|
||||
line = models.ForeignKey(
|
||||
TransferOrderLineItem,
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name=_('Line'),
|
||||
related_name='allocations',
|
||||
)
|
||||
|
||||
item = models.ForeignKey(
|
||||
'stock.StockItem',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='transfer_order_allocations',
|
||||
limit_choices_to={
|
||||
'part__virtual': False,
|
||||
'belongs_to': None,
|
||||
'sales_order': None,
|
||||
},
|
||||
verbose_name=_('Item'),
|
||||
help_text=_('Select stock item to allocate'),
|
||||
)
|
||||
|
||||
quantity = RoundingDecimalField(
|
||||
max_digits=15,
|
||||
decimal_places=5,
|
||||
validators=[MinValueValidator(0)],
|
||||
default=1,
|
||||
verbose_name=_('Quantity'),
|
||||
help_text=_('Enter stock allocation quantity'),
|
||||
)
|
||||
|
||||
def get_location(self):
|
||||
"""Return the <pk> value of the location associated with this allocation."""
|
||||
return self.item.location.id if self.item.location else None
|
||||
|
||||
def get_po(self):
|
||||
"""Return the PurchaseOrder associated with this allocation."""
|
||||
return self.item.purchase_order
|
||||
|
||||
def complete_allocation(self, user):
|
||||
"""Complete this allocation (called when the parent TransferOrder is marked as "completed").
|
||||
|
||||
Executes:
|
||||
- Determine if the referenced StockItem needs to be "split" (if allocated quantity != stock quantity)
|
||||
- Move the StockItem to the new location
|
||||
- Updates the transferred qty
|
||||
- If order is marked as "consume", reduce quantity rather than move
|
||||
"""
|
||||
order: TransferOrder = self.line.order
|
||||
self.item: stock.models.StockItem # for type hints
|
||||
self.line: TransferOrderLineItem # for type hints
|
||||
|
||||
# The allocation is the only thing linking this stock item to the transfer
|
||||
# As a result, we must keep the allocation present even after completion
|
||||
# This means allocations to transfer orders don't affect "available" stock
|
||||
# (otherwise it would permanently reduce available stock)
|
||||
|
||||
if order.consume:
|
||||
# rather than transferring the stock, we simply reduce its quantity to release it from tracked inventory
|
||||
# NOTE: if delete_on_deplete is enabled, this will result in the "transferred stock" panel being empty
|
||||
# after completion. A more sophesticated immutable tracking that doesn't rely on allocations
|
||||
# would be helpful here
|
||||
self.item.take_stock(
|
||||
quantity=self.quantity,
|
||||
user=user,
|
||||
code=StockHistoryCode.STOCK_REMOVE,
|
||||
transferorder=order,
|
||||
)
|
||||
else:
|
||||
if self.quantity < self.item.quantity:
|
||||
# update our own reference to the StockItem which was split
|
||||
self.item = self.item.splitStock(
|
||||
quantity=self.quantity,
|
||||
location=order.destination,
|
||||
user=user,
|
||||
transferorder=order,
|
||||
)
|
||||
self.save()
|
||||
else:
|
||||
# move item directly, we don't have to split
|
||||
self.item.move(
|
||||
location=order.destination, user=user, transferorder=order, notes=''
|
||||
)
|
||||
|
||||
# Update the transferred qty
|
||||
self.line.transferred += self.quantity
|
||||
self.line.save()
|
||||
|
||||
|
||||
def _touch_order_updated_at(instance):
|
||||
"""Bump updated_at on the parent order without triggering a full save."""
|
||||
if not InvenTree.ready.canAppAccessDatabase(allow_test=True):
|
||||
@@ -3190,6 +3851,16 @@ def _touch_order_updated_at(instance):
|
||||
@receiver(
|
||||
post_delete, sender=ReturnOrderExtraLine, dispatch_uid='ro_extraline_post_delete'
|
||||
)
|
||||
@receiver(
|
||||
post_save,
|
||||
sender=TransferOrderLineItem,
|
||||
dispatch_uid='transfer_order_lineitem_post_save',
|
||||
)
|
||||
@receiver(
|
||||
post_delete,
|
||||
sender=TransferOrderLineItem,
|
||||
dispatch_uid='transfer_order_lineitem_post_delete',
|
||||
)
|
||||
def update_order_on_lineitem_change(sender, instance, **kwargs):
|
||||
"""Update parent order updated_at when any line item is saved or deleted."""
|
||||
_touch_order_updated_at(instance)
|
||||
|
||||
@@ -44,6 +44,7 @@ from order.status_codes import (
|
||||
ReturnOrderLineStatus,
|
||||
ReturnOrderStatus,
|
||||
SalesOrderStatusGroups,
|
||||
TransferOrderStatusGroups,
|
||||
)
|
||||
from part.serializers import PartBriefSerializer
|
||||
from stock.status_codes import StockStatus
|
||||
@@ -2298,3 +2299,622 @@ class ReturnOrderExtraLineSerializer(
|
||||
'allow_null': True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@register_importer()
|
||||
class TransferOrderSerializer(
|
||||
NotesFieldMixin,
|
||||
InvenTreeCustomStatusSerializerMixin,
|
||||
AbstractOrderSerializer,
|
||||
InvenTreeModelSerializer,
|
||||
):
|
||||
"""Serializer for a TransferOrder object."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = order.models.TransferOrder
|
||||
fields = AbstractOrderSerializer.order_fields([
|
||||
'take_from',
|
||||
'take_from_detail',
|
||||
'destination',
|
||||
'destination_detail',
|
||||
'consume',
|
||||
'complete_date',
|
||||
])
|
||||
read_only_fields = ['creation_date']
|
||||
extra_kwargs = {}
|
||||
|
||||
def skip_create_fields(self):
|
||||
"""Skip these fields when instantiating a new object."""
|
||||
fields = super().skip_create_fields()
|
||||
|
||||
return [*fields, 'duplicate']
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""Add extra information to the queryset.
|
||||
|
||||
- Number of line items in the TransferOrder
|
||||
- Number of completed line items in the TransferOrder
|
||||
- Overdue status of the TransferOrder
|
||||
"""
|
||||
queryset = AbstractOrderSerializer.annotate_queryset(queryset)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
completed_lines=SubqueryCount(
|
||||
'lines', filter=Q(quantity__lte=F('transferred'))
|
||||
)
|
||||
)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
overdue=Case(
|
||||
When(
|
||||
order.models.TransferOrder.overdue_filter(),
|
||||
then=Value(True, output_field=BooleanField()),
|
||||
),
|
||||
default=Value(False, output_field=BooleanField()),
|
||||
)
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
take_from_detail = OptionalField(
|
||||
serializer_class=stock.serializers.LocationSerializer,
|
||||
serializer_kwargs={
|
||||
'source': 'take_from',
|
||||
'many': False,
|
||||
'read_only': True,
|
||||
'allow_null': True,
|
||||
},
|
||||
default_include=True,
|
||||
)
|
||||
|
||||
destination_detail = OptionalField(
|
||||
serializer_class=stock.serializers.LocationSerializer,
|
||||
serializer_kwargs={
|
||||
'source': 'destination',
|
||||
'many': False,
|
||||
'read_only': True,
|
||||
'allow_null': True,
|
||||
},
|
||||
default_include=True,
|
||||
)
|
||||
|
||||
|
||||
class TransferOrderHoldSerializer(OrderAdjustSerializer):
|
||||
"""Serializer for placing a TransferOrder on hold."""
|
||||
|
||||
def save(self):
|
||||
"""Save the serializer to 'hold' the order."""
|
||||
self.order.hold_order()
|
||||
|
||||
|
||||
class TransferOrderIssueSerializer(OrderAdjustSerializer):
|
||||
"""Serializer for issuing a transfer order."""
|
||||
|
||||
def save(self):
|
||||
"""Save the serializer to 'issue' the order."""
|
||||
self.order.issue_order()
|
||||
|
||||
|
||||
class TransferOrderCancelSerializer(OrderAdjustSerializer):
|
||||
"""Serializer for cancelling a TransferOrder."""
|
||||
|
||||
def save(self):
|
||||
"""Save the serializer to 'cancel' the order."""
|
||||
if not self.order.can_cancel:
|
||||
raise ValidationError(_('Order cannot be cancelled'))
|
||||
|
||||
self.order.cancel_order()
|
||||
|
||||
|
||||
class TransferOrderCompleteSerializer(OrderAdjustSerializer):
|
||||
"""Serializer for completing a transfer order."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
fields = ['accept_incomplete_allocation']
|
||||
|
||||
accept_incomplete_allocation = serializers.BooleanField(
|
||||
label=_('Accept Incomplete Allocation'),
|
||||
help_text=_('Allow order to complete with incomplete allocations'),
|
||||
required=False,
|
||||
default=False,
|
||||
)
|
||||
|
||||
def validate_accept_incomplete_allocation(self, value):
|
||||
"""Check if the 'accept_incomplete_allocation' field is required."""
|
||||
order = self.context['order']
|
||||
|
||||
if not value and not order.is_fully_allocated():
|
||||
raise ValidationError(_('Order has incomplete allocations'))
|
||||
|
||||
return value
|
||||
|
||||
def get_context_data(self):
|
||||
"""Custom context information for this serializer."""
|
||||
order = self.context['order']
|
||||
|
||||
return {'is_complete': order.is_completed()}
|
||||
|
||||
def validate(self, data):
|
||||
"""Custom validation for the serializer."""
|
||||
data = super().validate(data)
|
||||
self.order.can_complete(
|
||||
raise_error=True,
|
||||
allow_incomplete_lines=str2bool(
|
||||
data.get('accept_incomplete_allocation', False)
|
||||
),
|
||||
)
|
||||
return data
|
||||
|
||||
def save(self):
|
||||
"""Save the serializer to 'complete' the order."""
|
||||
request = self.context.get('request')
|
||||
data = self.validated_data
|
||||
user = request.user if request else None
|
||||
|
||||
self.order.complete_order(
|
||||
user=user,
|
||||
allow_incomplete_lines=data.get('accept_incomplete_allocation', False),
|
||||
)
|
||||
|
||||
|
||||
@register_importer()
|
||||
class TransferOrderLineItemSerializer(
|
||||
DataImportExportSerializerMixin,
|
||||
AbstractLineItemSerializer,
|
||||
InvenTreeModelSerializer,
|
||||
):
|
||||
"""Serializer for a TransferOrderLineItem object."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = order.models.TransferOrderLineItem
|
||||
fields = AbstractLineItemSerializer.line_fields([
|
||||
'allocated',
|
||||
'overdue',
|
||||
'part',
|
||||
'part_detail',
|
||||
'transferred',
|
||||
# Annotated fields for part stocking information
|
||||
'available_stock',
|
||||
'available_variant_stock',
|
||||
'building',
|
||||
'on_order',
|
||||
# Filterable detail fields
|
||||
])
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""Add some extra annotations to this queryset.
|
||||
|
||||
- "overdue" status (boolean field)
|
||||
- "available_quantity"
|
||||
- "building"
|
||||
- "on_order"
|
||||
"""
|
||||
queryset = queryset.annotate(
|
||||
overdue=Case(
|
||||
When(
|
||||
Q(order__status__in=TransferOrderStatusGroups.OPEN)
|
||||
& order.models.TransferOrderLineItem.OVERDUE_FILTER,
|
||||
then=Value(True, output_field=BooleanField()),
|
||||
),
|
||||
default=Value(False, output_field=BooleanField()),
|
||||
)
|
||||
)
|
||||
|
||||
# Annotate each line with the available stock quantity
|
||||
# To do this, we need to look at the total stock and any allocations
|
||||
queryset = queryset.alias(
|
||||
total_stock=part_filters.annotate_total_stock(reference='part__'),
|
||||
allocated_to_sales_orders=part_filters.annotate_sales_order_allocations(
|
||||
reference='part__'
|
||||
),
|
||||
allocated_to_build_orders=part_filters.annotate_build_order_allocations(
|
||||
reference='part__'
|
||||
),
|
||||
)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
available_stock=Greatest(
|
||||
ExpressionWrapper(
|
||||
F('total_stock')
|
||||
- F('allocated_to_sales_orders')
|
||||
- F('allocated_to_build_orders'),
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
0,
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
)
|
||||
|
||||
# Add information about the quantity of parts currently on order
|
||||
queryset = queryset.annotate(
|
||||
on_order=part_filters.annotate_on_order_quantity(reference='part__')
|
||||
)
|
||||
|
||||
# Add information about the quantity of parts currently in production
|
||||
queryset = queryset.annotate(
|
||||
building=part_filters.annotate_in_production_quantity(reference='part__')
|
||||
)
|
||||
|
||||
# Annotate total 'allocated' stock quantity
|
||||
queryset = queryset.annotate(
|
||||
allocated=Coalesce(
|
||||
SubquerySum('allocations__quantity'),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
order_detail = OptionalField(
|
||||
serializer_class=TransferOrderSerializer,
|
||||
serializer_kwargs={
|
||||
'source': 'order',
|
||||
'many': False,
|
||||
'read_only': True,
|
||||
'allow_null': True,
|
||||
},
|
||||
prefetch_fields=[
|
||||
'order__created_by',
|
||||
'order__responsible',
|
||||
'order__project_code',
|
||||
],
|
||||
)
|
||||
|
||||
part_detail = OptionalField(
|
||||
serializer_class=PartBriefSerializer,
|
||||
serializer_kwargs={
|
||||
'source': 'part',
|
||||
'many': False,
|
||||
'read_only': True,
|
||||
'allow_null': True,
|
||||
},
|
||||
prefetch_fields=['part__pricing_data'],
|
||||
)
|
||||
|
||||
# Annotated fields
|
||||
overdue = serializers.BooleanField(read_only=True, allow_null=True)
|
||||
available_stock = serializers.FloatField(read_only=True)
|
||||
available_variant_stock = serializers.FloatField(read_only=True)
|
||||
on_order = serializers.FloatField(label=_('On Order'), read_only=True)
|
||||
building = serializers.FloatField(label=_('In Production'), read_only=True)
|
||||
|
||||
quantity = InvenTreeDecimalField()
|
||||
|
||||
allocated = serializers.FloatField(read_only=True)
|
||||
|
||||
transferred = InvenTreeDecimalField(read_only=True)
|
||||
|
||||
|
||||
class TransferOrderAllocationItemSerializer(serializers.Serializer):
|
||||
"""A serializer for allocating a single stock-item against a TransferOrder line item."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
fields = ['line_item', 'stock_item', 'quantity']
|
||||
|
||||
line_item = serializers.PrimaryKeyRelatedField(
|
||||
queryset=order.models.TransferOrderLineItem.objects.all(),
|
||||
many=False,
|
||||
allow_null=False,
|
||||
required=True,
|
||||
label=_('Stock Item'),
|
||||
)
|
||||
|
||||
def validate_line_item(self, line_item):
|
||||
"""Custom validation for the 'line_item' field.
|
||||
|
||||
- Ensure the line_item is associated with the particular TransferOrder
|
||||
"""
|
||||
order = self.context['order']
|
||||
|
||||
# Ensure that the line item points to the correct order
|
||||
if line_item.order != order:
|
||||
raise ValidationError(_('Line item is not associated with this order'))
|
||||
|
||||
return line_item
|
||||
|
||||
stock_item = serializers.PrimaryKeyRelatedField(
|
||||
queryset=stock.models.StockItem.objects.all(),
|
||||
many=False,
|
||||
allow_null=False,
|
||||
required=True,
|
||||
label=_('Stock Item'),
|
||||
)
|
||||
|
||||
quantity = serializers.DecimalField(
|
||||
max_digits=15, decimal_places=5, min_value=Decimal(0), required=True
|
||||
)
|
||||
|
||||
def validate_quantity(self, quantity):
|
||||
"""Custom validation for the 'quantity' field."""
|
||||
if quantity <= 0:
|
||||
raise ValidationError(_('Quantity must be positive'))
|
||||
|
||||
return quantity
|
||||
|
||||
def validate(self, data):
|
||||
"""Custom validation for the serializer.
|
||||
|
||||
- Ensure that the quantity is 1 for serialized stock
|
||||
- Quantity cannot exceed the available amount
|
||||
"""
|
||||
data = super().validate(data)
|
||||
|
||||
stock_item = data['stock_item']
|
||||
quantity = data['quantity']
|
||||
|
||||
if stock_item.serialized and quantity != 1:
|
||||
raise ValidationError({
|
||||
'quantity': _('Quantity must be 1 for serialized stock item')
|
||||
})
|
||||
|
||||
q = normalize(stock_item.unallocated_quantity())
|
||||
|
||||
if quantity > q:
|
||||
raise ValidationError({'quantity': _(f'Available quantity ({q}) exceeded')})
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class TransferOrderLineItemAllocationSerializer(serializers.Serializer):
|
||||
"""DRF serializer for allocation of stock items against a transfer order line item."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
fields = ['items']
|
||||
|
||||
items = TransferOrderAllocationItemSerializer(many=True)
|
||||
|
||||
def validate(self, data):
|
||||
"""Serializer validation."""
|
||||
data = super().validate(data)
|
||||
|
||||
# Extract TransferOrder from serializer context
|
||||
# order = self.context['order']
|
||||
|
||||
items = data.get('items', [])
|
||||
|
||||
if len(items) == 0:
|
||||
raise ValidationError(_('Allocation items must be provided'))
|
||||
|
||||
return data
|
||||
|
||||
def save(self):
|
||||
"""Perform the allocation of items against this order."""
|
||||
data = self.validated_data
|
||||
|
||||
items = data['items']
|
||||
|
||||
with transaction.atomic():
|
||||
for entry in items:
|
||||
# Create a new TransferOrderAllocation
|
||||
allocation = order.models.TransferOrderAllocation(
|
||||
line=entry.get('line_item'),
|
||||
item=entry.get('stock_item'),
|
||||
quantity=entry.get('quantity'),
|
||||
)
|
||||
|
||||
allocation.full_clean()
|
||||
allocation.save()
|
||||
|
||||
|
||||
class TransferOrderAllocationSerializer(
|
||||
FilterableSerializerMixin, InvenTreeModelSerializer
|
||||
):
|
||||
"""Serializer for the TransferOrderAllocation model.
|
||||
|
||||
This includes some fields from the related model objects.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = order.models.TransferOrderAllocation
|
||||
fields = [
|
||||
'pk',
|
||||
'item',
|
||||
'quantity',
|
||||
# Annotated read-only fields
|
||||
'line',
|
||||
'part',
|
||||
'order',
|
||||
'serial',
|
||||
'location',
|
||||
# Extra detail fields
|
||||
'item_detail',
|
||||
'part_detail',
|
||||
'order_detail',
|
||||
'location_detail',
|
||||
]
|
||||
read_only_fields = ['line', '']
|
||||
|
||||
part = serializers.PrimaryKeyRelatedField(source='item.part', read_only=True)
|
||||
order = serializers.PrimaryKeyRelatedField(
|
||||
source='line.order', many=False, read_only=True
|
||||
)
|
||||
serial = serializers.CharField(source='get_serial', read_only=True, allow_null=True)
|
||||
quantity = serializers.FloatField(read_only=False)
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
source='item.location', many=False, read_only=True
|
||||
)
|
||||
|
||||
# Extra detail fields
|
||||
order_detail = OptionalField(
|
||||
serializer_class=TransferOrderSerializer,
|
||||
serializer_kwargs={
|
||||
'source': 'line.order',
|
||||
'many': False,
|
||||
'read_only': True,
|
||||
'allow_null': True,
|
||||
},
|
||||
)
|
||||
|
||||
part_detail = OptionalField(
|
||||
serializer_class=PartBriefSerializer,
|
||||
serializer_kwargs={
|
||||
'source': 'item.part',
|
||||
'many': False,
|
||||
'read_only': True,
|
||||
'allow_null': True,
|
||||
},
|
||||
)
|
||||
|
||||
item_detail = OptionalField(
|
||||
serializer_class=stock.serializers.StockItemSerializer,
|
||||
serializer_kwargs={
|
||||
'source': 'item',
|
||||
'many': False,
|
||||
'read_only': True,
|
||||
'allow_null': True,
|
||||
'part_detail': False,
|
||||
'location_detail': False,
|
||||
'supplier_part_detail': False,
|
||||
},
|
||||
)
|
||||
|
||||
location_detail = OptionalField(
|
||||
serializer_class=stock.serializers.LocationBriefSerializer,
|
||||
serializer_kwargs={
|
||||
'source': 'item.location',
|
||||
'many': False,
|
||||
'read_only': True,
|
||||
'allow_null': True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TransferOrderSerialAllocationSerializer(serializers.Serializer):
|
||||
"""DRF serializer for allocation of serial numbers against a transfer order."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
fields = ['line_item', 'quantity', 'serial_numbers']
|
||||
|
||||
line_item = serializers.PrimaryKeyRelatedField(
|
||||
queryset=order.models.TransferOrderLineItem.objects.all(),
|
||||
many=False,
|
||||
required=True,
|
||||
allow_null=False,
|
||||
label=_('Line Item'),
|
||||
)
|
||||
|
||||
def validate_line_item(self, line_item):
|
||||
"""Ensure that the line_item is valid."""
|
||||
order = self.context['order']
|
||||
|
||||
# Ensure that the line item points to the correct order
|
||||
if line_item.order != order:
|
||||
raise ValidationError(_('Line item is not associated with this order'))
|
||||
|
||||
return line_item
|
||||
|
||||
quantity = serializers.IntegerField(
|
||||
min_value=1, required=True, allow_null=False, label=_('Quantity')
|
||||
)
|
||||
|
||||
serial_numbers = serializers.CharField(
|
||||
label=_('Serial Numbers'),
|
||||
help_text=_('Enter serial numbers to allocate'),
|
||||
required=True,
|
||||
allow_blank=False,
|
||||
)
|
||||
|
||||
def validate(self, data):
|
||||
"""Validation for the serializer.
|
||||
|
||||
- Ensure the serial_numbers and quantity fields match
|
||||
- Check that all serial numbers exist
|
||||
- Check that the serial numbers are not yet allocated
|
||||
"""
|
||||
data = super().validate(data)
|
||||
|
||||
line_item = data['line_item']
|
||||
quantity = data['quantity']
|
||||
serial_numbers = data['serial_numbers']
|
||||
|
||||
part = line_item.part
|
||||
|
||||
try:
|
||||
data['serials'] = extract_serial_numbers(
|
||||
serial_numbers, quantity, part.get_latest_serial_number(), part=part
|
||||
)
|
||||
except DjangoValidationError as e:
|
||||
raise ValidationError({'serial_numbers': e.messages})
|
||||
|
||||
serials_not_exist = set()
|
||||
serials_unavailable = set()
|
||||
stock_items_to_allocate = []
|
||||
|
||||
for serial in data['serials']:
|
||||
serial = str(serial).strip()
|
||||
|
||||
items = stock.models.StockItem.objects.filter(
|
||||
part=part, serial=serial, quantity=1
|
||||
)
|
||||
|
||||
if not items.exists():
|
||||
serials_not_exist.add(str(serial))
|
||||
continue
|
||||
|
||||
stock_item = items[0]
|
||||
|
||||
if not stock_item.in_stock:
|
||||
serials_unavailable.add(str(serial))
|
||||
continue
|
||||
|
||||
if stock_item.unallocated_quantity() < 1:
|
||||
serials_unavailable.add(str(serial))
|
||||
continue
|
||||
|
||||
# At this point, the serial number is valid, and can be added to the list
|
||||
stock_items_to_allocate.append(stock_item)
|
||||
|
||||
if len(serials_not_exist) > 0:
|
||||
error_msg = _('No match found for the following serial numbers')
|
||||
error_msg += ': '
|
||||
error_msg += ','.join(sorted(serials_not_exist))
|
||||
|
||||
raise ValidationError({'serial_numbers': error_msg})
|
||||
|
||||
if len(serials_unavailable) > 0:
|
||||
error_msg = _('The following serial numbers are unavailable')
|
||||
error_msg += ': '
|
||||
error_msg += ','.join(sorted(serials_unavailable))
|
||||
|
||||
raise ValidationError({'serial_numbers': error_msg})
|
||||
|
||||
data['stock_items'] = stock_items_to_allocate
|
||||
|
||||
return data
|
||||
|
||||
def save(self):
|
||||
"""Allocate stock items against the transfer order."""
|
||||
data = self.validated_data
|
||||
|
||||
line_item = data['line_item']
|
||||
stock_items = data['stock_items']
|
||||
|
||||
allocations = []
|
||||
|
||||
for stock_item in stock_items:
|
||||
# Create a new TransferOrderAllocation
|
||||
allocations.append(
|
||||
order.models.TransferOrderAllocation(
|
||||
line=line_item, item=stock_item, quantity=1
|
||||
)
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
order.models.TransferOrderAllocation.objects.bulk_create(allocations)
|
||||
|
||||
@@ -115,3 +115,30 @@ class ReturnOrderLineStatus(StatusCode):
|
||||
|
||||
# Item is rejected
|
||||
REJECT = 60, _('Reject'), ColorEnum.danger
|
||||
|
||||
|
||||
class TransferOrderStatus(StatusCode):
|
||||
"""Defines a set of status codes for a TransferOrder."""
|
||||
|
||||
# Order status codes
|
||||
PENDING = 10, _('Pending'), ColorEnum.secondary # Order is pending (not yet issued)
|
||||
ISSUED = 20, _('Issued'), ColorEnum.primary # Order has been issued
|
||||
ON_HOLD = 25, _('On Hold'), ColorEnum.warning # Order is on hold
|
||||
COMPLETE = 30, _('Complete'), ColorEnum.success # Order has been completed
|
||||
CANCELLED = 40, _('Cancelled'), ColorEnum.danger # Order was cancelled
|
||||
|
||||
|
||||
class TransferOrderStatusGroups:
|
||||
"""Groups for TransferOrderStatus codes."""
|
||||
|
||||
# Open orders
|
||||
OPEN = [
|
||||
TransferOrderStatus.PENDING.value,
|
||||
TransferOrderStatus.ON_HOLD.value,
|
||||
TransferOrderStatus.ISSUED.value,
|
||||
]
|
||||
|
||||
# Failed orders
|
||||
FAILED = [TransferOrderStatus.CANCELLED.value]
|
||||
|
||||
COMPLETE = [TransferOrderStatus.COMPLETE.value]
|
||||
|
||||
@@ -27,6 +27,8 @@ from order.status_codes import (
|
||||
ReturnOrderStatus,
|
||||
SalesOrderStatus,
|
||||
SalesOrderStatusGroups,
|
||||
TransferOrderStatus,
|
||||
TransferOrderStatusGroups,
|
||||
)
|
||||
from part.models import Part
|
||||
from stock.models import StockItem, StockLocation
|
||||
@@ -46,9 +48,10 @@ class OrderTest(InvenTreeAPITestCase):
|
||||
'stock',
|
||||
'order',
|
||||
'sales_order',
|
||||
'transfer_order',
|
||||
]
|
||||
|
||||
roles = ['purchase_order.change', 'sales_order.change']
|
||||
roles = ['purchase_order.change', 'sales_order.change', 'transfer_order.change']
|
||||
|
||||
def filter(self, filters, count):
|
||||
"""Test API filters."""
|
||||
@@ -2892,3 +2895,848 @@ class ReturnOrderLineItemTests(InvenTreeAPITestCase):
|
||||
|
||||
line = models.ReturnOrderLineItem.objects.get(pk=1)
|
||||
self.assertEqual(float(line.price.amount), 15.75)
|
||||
|
||||
|
||||
class TransferOrderTest(OrderTest):
|
||||
"""Tests for the TransferOrder API."""
|
||||
|
||||
LIST_URL = reverse('api-transfer-order-list')
|
||||
|
||||
def test_transfer_order_list(self):
|
||||
"""Test the TransferOrder list API endpoint."""
|
||||
# all orders
|
||||
self.filter({}, 5)
|
||||
|
||||
# filter by outstanding
|
||||
self.filter({'outstanding': True}, 3)
|
||||
self.filter({'outstanding': False}, 2)
|
||||
|
||||
# Filter by status
|
||||
self.filter({'status': TransferOrderStatus.PENDING.value}, 1)
|
||||
self.filter({'status': SalesOrderStatus.COMPLETE.value}, 1)
|
||||
self.filter({'status': 99}, 0) # Invalid
|
||||
|
||||
# Filter by "reference"
|
||||
self.filter({'reference': 'TO-123'}, 1)
|
||||
self.filter({'reference': 'TO-999'}, 0)
|
||||
|
||||
# Filter by "assigned_to_me"
|
||||
self.filter({'assigned_to_me': 1}, 0)
|
||||
self.filter({'assigned_to_me': 0}, 5)
|
||||
|
||||
def test_overdue(self):
|
||||
"""Test "overdue" status."""
|
||||
self.filter({'overdue': True}, 0)
|
||||
self.filter({'overdue': False}, 5)
|
||||
|
||||
# pick two orders that are still open (not cancelled or complete)
|
||||
for pk in [1, 4]:
|
||||
order = models.TransferOrder.objects.get(pk=pk)
|
||||
order.target_date = datetime.now().date() - timedelta(days=10)
|
||||
order.save()
|
||||
|
||||
self.filter({'overdue': True}, 2)
|
||||
self.filter({'overdue': False}, 3)
|
||||
|
||||
def test_transfer_order_detail(self):
|
||||
"""Test the TransferOrder detail endpoint."""
|
||||
url = '/api/order/transfer-order/1/'
|
||||
|
||||
response = self.get(url)
|
||||
|
||||
data = response.data
|
||||
|
||||
self.assertEqual(data['pk'], 1)
|
||||
|
||||
def test_transfer_order_attachments(self):
|
||||
"""Test the list endpoint for the Transfer Order Attachments."""
|
||||
url = reverse('api-attachment-list')
|
||||
|
||||
# Filter by 'transferorder'
|
||||
self.get(
|
||||
url, data={'model_type': 'transferorder', 'model_id': 1}, expected_code=200
|
||||
)
|
||||
|
||||
def test_transfer_order_operations(self):
|
||||
"""Test that we can create / edit and delete a TransferOrder via the API."""
|
||||
n = models.TransferOrder.objects.count()
|
||||
|
||||
url = reverse('api-transfer-order-list')
|
||||
|
||||
# Initially we do not have "add" permission for the TransferOrder model,
|
||||
# so this POST request should return 403 (denied)
|
||||
response = self.post(
|
||||
url,
|
||||
{'reference': 'TO-43245', 'description': 'Transfer order'},
|
||||
expected_code=403,
|
||||
)
|
||||
|
||||
self.assignRole('transfer_order.add')
|
||||
|
||||
# Now we should be able to create a TransferOrder via the API
|
||||
response = self.post(
|
||||
url,
|
||||
{'reference': 'TO-12345', 'description': 'Transfer order'},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
# Check that the new order has been created
|
||||
self.assertEqual(models.TransferOrder.objects.count(), n + 1)
|
||||
|
||||
# Grab the PK for the newly created TransferOrder
|
||||
pk = response.data['pk']
|
||||
|
||||
# Basic checks against the newly created TransferOrder
|
||||
so = models.TransferOrder.objects.get(pk=pk)
|
||||
self.assertEqual(so.reference, 'TO-12345')
|
||||
self.assertEqual(so.created_by.username, 'testuser')
|
||||
|
||||
# Try to create a TO with identical reference (should fail)
|
||||
response = self.post(
|
||||
url,
|
||||
{
|
||||
'customer': 4,
|
||||
'reference': 'TO-12345',
|
||||
'description': 'Another transfer order',
|
||||
},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
url = reverse('api-transfer-order-detail', kwargs={'pk': pk})
|
||||
|
||||
# Extract detail info for the TransferOrder
|
||||
response = self.get(url)
|
||||
self.assertEqual(response.data['reference'], 'TO-12345')
|
||||
|
||||
# Try to alter (edit) the TransferOrder
|
||||
# Initially try with an invalid reference field value
|
||||
response = self.patch(url, {'reference': 'TO-12345-a'}, expected_code=400)
|
||||
|
||||
response = self.patch(url, {'reference': 'TO-12346'}, expected_code=200)
|
||||
|
||||
# Reference should have changed
|
||||
self.assertEqual(response.data['reference'], 'TO-12346')
|
||||
|
||||
# Now, let's try to delete this TransferOrder
|
||||
# Initially, we do not have the required permission
|
||||
response = self.delete(url, expected_code=403)
|
||||
|
||||
self.assignRole('transfer_order.delete')
|
||||
|
||||
response = self.delete(url, expected_code=204)
|
||||
|
||||
# Check that the number of transfer orders has decreased
|
||||
self.assertEqual(models.TransferOrder.objects.count(), n)
|
||||
|
||||
# And the resource should no longer be available
|
||||
response = self.get(url, expected_code=404)
|
||||
|
||||
def test_transfer_order_create(self):
|
||||
"""Test that we can create a new TransferOrder via the API."""
|
||||
self.assignRole('transfer_order.add')
|
||||
|
||||
url = reverse('api-transfer-order-list')
|
||||
|
||||
# Will fail due to invalid reference field
|
||||
response = self.post(
|
||||
url,
|
||||
{'reference': '1234566778', 'description': 'A test transfer order'},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
'Reference must match required pattern', str(response.data['reference'])
|
||||
)
|
||||
|
||||
self.post(
|
||||
url,
|
||||
{'reference': 'TO-12345', 'description': 'A better test transfer order'},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
def test_transfer_order_cancel(self):
|
||||
"""Test API endpoint for cancelling a TransferOrder."""
|
||||
to = models.TransferOrder.objects.get(pk=1)
|
||||
|
||||
self.assertEqual(to.status, TransferOrderStatus.PENDING)
|
||||
|
||||
url = reverse('api-transfer-order-cancel', kwargs={'pk': to.pk})
|
||||
|
||||
# Try to cancel, without permission
|
||||
self.post(url, {}, expected_code=403)
|
||||
|
||||
self.assignRole('transfer_order.add')
|
||||
|
||||
self.post(url, {}, expected_code=201)
|
||||
|
||||
to.refresh_from_db()
|
||||
|
||||
self.assertEqual(to.status, TransferOrderStatus.CANCELLED)
|
||||
|
||||
def test_transfer_order_hold(self):
|
||||
"""Test API endpoint for holdling a TransferOrder."""
|
||||
to = models.TransferOrder.objects.get(pk=1)
|
||||
|
||||
self.assertEqual(to.status, TransferOrderStatus.PENDING)
|
||||
|
||||
url = reverse('api-transfer-order-hold', kwargs={'pk': to.pk})
|
||||
|
||||
# Try to hold, without permission
|
||||
self.post(url, {}, expected_code=403)
|
||||
|
||||
self.assignRole('transfer_order.add')
|
||||
|
||||
self.post(url, {}, expected_code=201)
|
||||
|
||||
to.refresh_from_db()
|
||||
|
||||
self.assertEqual(to.status, TransferOrderStatus.ON_HOLD)
|
||||
|
||||
def test_transfer_order_calendar(self):
|
||||
"""Test the calendar export endpoint."""
|
||||
# Create required transfer orders
|
||||
self.assignRole('transfer_order.add')
|
||||
|
||||
for i in range(1, 9):
|
||||
self.post(
|
||||
reverse('api-transfer-order-list'),
|
||||
{
|
||||
'reference': f'TO-1100000{i}',
|
||||
'description': f'Calendar SO {i}',
|
||||
'target_date': f'2024-12-{i:02d}',
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
# Cancel a few orders - these will not show in incomplete view below
|
||||
for to in models.TransferOrder.objects.filter(target_date__isnull=False):
|
||||
if to.reference in [
|
||||
'TO-11000006',
|
||||
'TO-11000007',
|
||||
'TO-11000008',
|
||||
'TO-11000009',
|
||||
]:
|
||||
self.post(
|
||||
reverse('api-transfer-order-cancel', kwargs={'pk': to.pk}),
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
url = reverse('api-po-so-calendar', kwargs={'ordertype': 'transfer-order'})
|
||||
|
||||
# Test without completed orders
|
||||
response = self.get(url, expected_code=200, format=None)
|
||||
|
||||
number_orders = len(
|
||||
models.TransferOrder.objects.filter(target_date__isnull=False).filter(
|
||||
status__lt=TransferOrderStatus.COMPLETE.value
|
||||
)
|
||||
)
|
||||
|
||||
# Transform content to a Calendar object
|
||||
calendar = Calendar.from_ical(response.content)
|
||||
n_events = 0
|
||||
# Count number of events in calendar
|
||||
for component in calendar.walk():
|
||||
if component.name == 'VEVENT':
|
||||
n_events += 1
|
||||
|
||||
self.assertGreaterEqual(n_events, 1)
|
||||
self.assertEqual(number_orders, n_events)
|
||||
|
||||
# Test with completed orders
|
||||
response = self.get(
|
||||
url, data={'include_completed': 'True'}, expected_code=200, format=None
|
||||
)
|
||||
|
||||
number_orders_incl_complete = len(
|
||||
models.TransferOrder.objects.filter(target_date__isnull=False)
|
||||
)
|
||||
self.assertGreater(number_orders_incl_complete, number_orders)
|
||||
|
||||
# Transform content to a Calendar object
|
||||
calendar = Calendar.from_ical(response.content)
|
||||
n_events = 0
|
||||
# Count number of events in calendar
|
||||
for component in calendar.walk():
|
||||
if component.name == 'VEVENT':
|
||||
n_events += 1
|
||||
|
||||
self.assertGreaterEqual(n_events, 1)
|
||||
self.assertEqual(number_orders_incl_complete, n_events)
|
||||
|
||||
def test_export(self):
|
||||
"""Test we can export the TransferOrder list."""
|
||||
n = models.TransferOrder.objects.count()
|
||||
|
||||
# Check there are some sales orders
|
||||
self.assertGreater(n, 0)
|
||||
|
||||
# Download file, check we get a 200 response
|
||||
for fmt in ['csv', 'xlsx', 'tsv']:
|
||||
self.export_data(
|
||||
reverse('api-transfer-order-list'),
|
||||
export_format=fmt,
|
||||
decode=fmt == 'csv',
|
||||
expected_code=200,
|
||||
expected_fn=r'InvenTree_TransferOrder_.+',
|
||||
)
|
||||
|
||||
def test_transfer_order_complete(self):
|
||||
"""Tests for marking a TransferOrder as complete."""
|
||||
self.assignRole('transfer_order.add')
|
||||
destination = StockLocation.objects.first()
|
||||
# Let's create a TransferOrder
|
||||
to = models.TransferOrder.objects.create(
|
||||
reference='TO-12345', description='Test TO'
|
||||
)
|
||||
|
||||
self.assertEqual(to.status, TransferOrderStatus.PENDING.value)
|
||||
|
||||
# Create a line item
|
||||
part = Part.objects.exclude(virtual=True).first()
|
||||
|
||||
line = models.TransferOrderLineItem.objects.create(
|
||||
order=to, part=part, quantity=10
|
||||
)
|
||||
|
||||
# issue the order
|
||||
url = reverse('api-transfer-order-issue', kwargs={'pk': to.pk})
|
||||
self.post(url, {}, expected_code=201)
|
||||
to.refresh_from_db()
|
||||
self.assertEqual(to.status, TransferOrderStatus.ISSUED.value)
|
||||
|
||||
# Allocate some stock
|
||||
item = StockItem.objects.create(
|
||||
part=part, quantity=100, location=None, batch='transfer-order-test'
|
||||
)
|
||||
short_allocation = models.TransferOrderAllocation.objects.create(
|
||||
quantity=5, line=line, item=item
|
||||
)
|
||||
|
||||
# attempt to complete the order, but fail because there are incomplete allocations
|
||||
url = reverse('api-transfer-order-complete', kwargs={'pk': to.pk})
|
||||
response = self.post(url, {}, expected_code=400)
|
||||
self.assertIn('has incomplete allocations', str(response.data))
|
||||
# allocate more stock
|
||||
short_allocation.delete()
|
||||
models.TransferOrderAllocation.objects.create(quantity=10, line=line, item=item)
|
||||
|
||||
# attempt to complete the order, but fail because there is no destination yet
|
||||
url = reverse('api-transfer-order-complete', kwargs={'pk': to.pk})
|
||||
response = self.post(url, {}, expected_code=400)
|
||||
self.assertIn('until a destination location is set', str(response.data))
|
||||
# add destination
|
||||
to.destination = destination
|
||||
to.save()
|
||||
|
||||
# Ok, now we should be able to "complete" the transfer via the API
|
||||
url = reverse('api-transfer-order-complete', kwargs={'pk': to.pk})
|
||||
self.post(url, {}, expected_code=201)
|
||||
|
||||
to.refresh_from_db()
|
||||
self.assertEqual(to.status, TransferOrderStatus.COMPLETE.value)
|
||||
self.assertIsNotNone(to.complete_date)
|
||||
|
||||
# Now, let's try *again* (it should fail as the order is already complete)
|
||||
response = self.post(url, {}, expected_code=400)
|
||||
self.assertIn('Order is already complete', str(response.data))
|
||||
|
||||
# Now, we make sure the affected stock was transferred to the correct location
|
||||
StockItem.objects.get(
|
||||
part=part, quantity=10, batch='transfer-order-test', location=destination
|
||||
)
|
||||
|
||||
def test_transfer_order_consume(self):
|
||||
"""Tests for marking a TransferOrder consume the stock it 'transfers'."""
|
||||
self.assignRole('transfer_order.add')
|
||||
destination = StockLocation.objects.first()
|
||||
# Let's create a TransferOrder
|
||||
to = models.TransferOrder.objects.create(
|
||||
reference='TO-12345',
|
||||
description='Test TO',
|
||||
consume=True,
|
||||
destination=destination,
|
||||
)
|
||||
|
||||
self.assertEqual(to.status, TransferOrderStatus.PENDING.value)
|
||||
|
||||
# Create a line item
|
||||
part = Part.objects.exclude(virtual=True).first()
|
||||
|
||||
line = models.TransferOrderLineItem.objects.create(
|
||||
order=to, part=part, quantity=10
|
||||
)
|
||||
|
||||
# issue the order
|
||||
url = reverse('api-transfer-order-issue', kwargs={'pk': to.pk})
|
||||
self.post(url, {}, expected_code=201)
|
||||
to.refresh_from_db()
|
||||
self.assertEqual(to.status, TransferOrderStatus.ISSUED.value)
|
||||
|
||||
# Allocate some stock
|
||||
item = StockItem.objects.create(
|
||||
part=part, quantity=100, location=None, batch='transfer-order-test'
|
||||
)
|
||||
models.TransferOrderAllocation.objects.create(quantity=10, line=line, item=item)
|
||||
|
||||
# Ok, now we should be able to "complete" the transfer via the API
|
||||
url = reverse('api-transfer-order-complete', kwargs={'pk': to.pk})
|
||||
self.post(url, {}, expected_code=201)
|
||||
|
||||
to.refresh_from_db()
|
||||
self.assertEqual(to.status, TransferOrderStatus.COMPLETE.value)
|
||||
self.assertIsNotNone(to.complete_date)
|
||||
|
||||
# Now, we make sure the affected stock was 'consumed', reducing available quantity
|
||||
item.refresh_from_db()
|
||||
self.assertEqual(item.quantity, 90)
|
||||
|
||||
# and that it wasn't transferred to the destination
|
||||
with self.assertRaises(StockItem.DoesNotExist):
|
||||
StockItem.objects.get(
|
||||
part=part,
|
||||
quantity=10,
|
||||
batch='transfer-order-test',
|
||||
location=destination,
|
||||
)
|
||||
|
||||
def test_output_options(self):
|
||||
"""Test the output options for the TransferOrder detail endpoint."""
|
||||
self.run_output_test(
|
||||
reverse('api-transfer-order-detail', kwargs={'pk': 1}),
|
||||
['take_from_detail', 'destination_detail'],
|
||||
)
|
||||
|
||||
|
||||
class TransferOrderLineItemTest(OrderTest):
|
||||
"""Tests for the TransferOrderLineItem API."""
|
||||
|
||||
LIST_URL = reverse('api-transfer-order-line-list')
|
||||
|
||||
# adjust counts in asserts based on those created in setUpTestData
|
||||
# plus those in fixtures
|
||||
NUM_LINE_ITEMS_IN_FIXTURES = 2
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""Init routine for this unit test class."""
|
||||
super().setUpTestData()
|
||||
|
||||
# List of 'transferrable' parts
|
||||
parts = Part.objects.exclude(virtual=True)
|
||||
|
||||
lines = []
|
||||
|
||||
# Create a bunch of TransferOrderLineItems for each order
|
||||
for idx, to in enumerate(models.TransferOrder.objects.all()):
|
||||
for part in parts:
|
||||
lines.append(
|
||||
models.TransferOrderLineItem(
|
||||
order=to,
|
||||
part=part,
|
||||
quantity=(idx + 1) * 5,
|
||||
reference=f'Order {to.reference} - line {idx}',
|
||||
)
|
||||
)
|
||||
|
||||
# Bulk create
|
||||
models.TransferOrderLineItem.objects.bulk_create(lines)
|
||||
|
||||
cls.url = reverse('api-transfer-order-line-list')
|
||||
|
||||
def test_transfer_order_line_list(self):
|
||||
"""Test list endpoint."""
|
||||
response = self.get(self.url, {}, expected_code=200)
|
||||
|
||||
n = models.TransferOrderLineItem.objects.count()
|
||||
|
||||
# We should have received *all* lines
|
||||
self.assertEqual(len(response.data), n)
|
||||
|
||||
# List *all* lines, but paginate
|
||||
response = self.get(self.url, {'limit': 5}, expected_code=200)
|
||||
|
||||
self.assertEqual(response.data['count'], n)
|
||||
self.assertEqual(len(response.data['results']), 5)
|
||||
|
||||
n_orders = models.TransferOrder.objects.count()
|
||||
n_parts = Part.objects.exclude(virtual=True).count()
|
||||
|
||||
# List by part
|
||||
# fixures add line items, avoid those here with [:3] for predictable counts
|
||||
for part in Part.objects.exclude(virtual=True)[:3]:
|
||||
response = self.get(self.url, {'part': part.pk, 'limit': 10})
|
||||
self.assertEqual(response.data['count'], n_orders)
|
||||
|
||||
# List by order
|
||||
# fixures add line items, avoid those here with [:3] for predictable counts
|
||||
for order in models.TransferOrder.objects.all()[:3]:
|
||||
response = self.get(self.url, {'order': order.pk, 'limit': 10})
|
||||
# count of line items equal to number of parts because
|
||||
# we created a line item per part on each order in setUpTestData
|
||||
self.assertEqual(response.data['count'], n_parts)
|
||||
|
||||
# Filter by 'completed' status
|
||||
self.filter({'completed': 1}, 1)
|
||||
self.filter({'completed': 0}, n - 1)
|
||||
|
||||
# Filter by 'allocated' status
|
||||
self.filter({'allocated': 'true'}, 2)
|
||||
self.filter({'allocated': 'false'}, n - 2)
|
||||
|
||||
def test_transfer_order_line_allocated_filters(self):
|
||||
"""Test filtering by allocation status for a TransferOrderLineItem."""
|
||||
self.assignRole('transfer_order.add')
|
||||
|
||||
destination = StockLocation.objects.first()
|
||||
assert destination
|
||||
|
||||
response = self.post(
|
||||
reverse('api-transfer-order-list'),
|
||||
{
|
||||
'reference': 'TO-12345',
|
||||
'description': 'Test Transfer Order',
|
||||
'destination': destination.pk,
|
||||
},
|
||||
)
|
||||
|
||||
order_id = response.data['pk']
|
||||
order = models.TransferOrder.objects.get(pk=order_id)
|
||||
|
||||
transfer_order_line_url = reverse('api-transfer-order-line-list')
|
||||
|
||||
# Initially, there should be no line items against this order
|
||||
response = self.get(transfer_order_line_url, {'order': order_id})
|
||||
|
||||
self.assertEqual(len(response.data), 0)
|
||||
|
||||
parts = [25, 50, 100]
|
||||
|
||||
# Let's create some new line items
|
||||
for part_id in parts:
|
||||
self.post(
|
||||
transfer_order_line_url,
|
||||
{'order': order_id, 'part': part_id, 'quantity': 10},
|
||||
)
|
||||
|
||||
# Should be three items now
|
||||
response = self.get(transfer_order_line_url, {'order': order_id})
|
||||
|
||||
self.assertEqual(len(response.data), 3)
|
||||
|
||||
for item in response.data:
|
||||
# Check that the line item has been created
|
||||
self.assertEqual(item['order'], order_id)
|
||||
|
||||
# Check that the line quantities are correct
|
||||
self.assertEqual(item['quantity'], 10)
|
||||
self.assertEqual(item['allocated'], 0)
|
||||
self.assertEqual(item['transferred'], 0)
|
||||
|
||||
# Initial API filters should return no results
|
||||
self.filter({'order': order_id, 'allocated': 1}, 0)
|
||||
self.filter({'order': order_id, 'completed': 1}, 0)
|
||||
|
||||
# issue the order
|
||||
order_issue_url = reverse('api-transfer-order-issue', kwargs={'pk': order.pk})
|
||||
self.post(order_issue_url, {}, expected_code=201)
|
||||
|
||||
# Next, allocate stock against 2 line items
|
||||
for item in parts[:2]:
|
||||
p = Part.objects.get(pk=item)
|
||||
s = StockItem.objects.create(part=p, quantity=100)
|
||||
l = models.TransferOrderLineItem.objects.filter(order=order, part=p).first()
|
||||
assert l
|
||||
|
||||
# Allocate against the API
|
||||
self.post(
|
||||
reverse('api-transfer-order-allocate', kwargs={'pk': order.pk}),
|
||||
{'items': [{'line_item': l.pk, 'stock_item': s.pk, 'quantity': 10}]},
|
||||
)
|
||||
|
||||
# Filter by 'fully allocated' status
|
||||
self.filter({'order': order_id, 'allocated': 1}, 2)
|
||||
self.filter({'order': order_id, 'allocated': 0}, 1)
|
||||
|
||||
self.filter({'order': order_id, 'completed': 1}, 0)
|
||||
self.filter({'order': order_id, 'completed': 0}, 3)
|
||||
|
||||
# Finally, attempt to transfer this line item
|
||||
# we have incomplete allocations, so must specify arg
|
||||
self.post(
|
||||
reverse('api-transfer-order-complete', kwargs={'pk': order.pk}),
|
||||
{'accept_incomplete_allocation': 'true'},
|
||||
)
|
||||
|
||||
# Filter by 'completed' status
|
||||
self.filter({'order': order_id, 'completed': 1}, 2)
|
||||
self.filter({'order': order_id, 'completed': 0}, 1)
|
||||
|
||||
def test_output_options(self):
|
||||
"""Test the various output options for the TransferOrderLineItem detail endpoint."""
|
||||
self.run_output_test(
|
||||
reverse('api-transfer-order-line-detail', kwargs={'pk': 1}),
|
||||
['part_detail', 'order_detail'],
|
||||
)
|
||||
|
||||
|
||||
class TransferOrderDownloadTest(OrderTest):
|
||||
"""Unit tests for downloading TransferOrder data via the API endpoint."""
|
||||
|
||||
def test_download_fail(self):
|
||||
"""Test that downloading without the 'export' option fails."""
|
||||
url = reverse('api-transfer-order-list')
|
||||
|
||||
response = self.export_data(url, export_plugin='no-plugin', expected_code=400)
|
||||
self.assertIn('is not a valid choice', str(response['export_plugin']))
|
||||
|
||||
def test_download_xlsx(self):
|
||||
"""Test xlsx file download."""
|
||||
url = reverse('api-transfer-order-list')
|
||||
|
||||
# Download .xls file
|
||||
with self.export_data(
|
||||
url, export_format='xlsx', expected_code=200, decode=False
|
||||
) as file:
|
||||
self.assertIsInstance(file, io.BytesIO)
|
||||
|
||||
def test_download_csv(self):
|
||||
"""Test that the list of transfer orders can be downloaded as a .csv file."""
|
||||
url = reverse('api-transfer-order-list')
|
||||
|
||||
required_cols = [
|
||||
'Line Items',
|
||||
'Completed Lines',
|
||||
'ID',
|
||||
'Reference',
|
||||
'Order Status',
|
||||
'Description',
|
||||
'Project Code',
|
||||
'Responsible',
|
||||
'Consume Stock',
|
||||
]
|
||||
|
||||
excluded_cols = ['metadata']
|
||||
|
||||
# Download .xls file
|
||||
with self.export_data(url, export_format='csv') as file:
|
||||
data = self.process_csv(
|
||||
file,
|
||||
required_cols=required_cols,
|
||||
excluded_cols=excluded_cols,
|
||||
required_rows=models.TransferOrder.objects.count(),
|
||||
)
|
||||
|
||||
for line in data:
|
||||
order = models.TransferOrder.objects.get(pk=line['ID'])
|
||||
|
||||
self.assertEqual(line['Description'], order.description)
|
||||
self.assertEqual(line['Order Status'], str(order.status))
|
||||
|
||||
# Download only outstanding transfer orders
|
||||
with self.export_data(url, {'outstanding': True}, export_format='tsv') as file:
|
||||
self.process_csv(
|
||||
file,
|
||||
required_cols=required_cols,
|
||||
excluded_cols=excluded_cols,
|
||||
required_rows=models.TransferOrder.objects.filter(
|
||||
status__in=TransferOrderStatusGroups.OPEN
|
||||
).count(),
|
||||
delimiter='\t',
|
||||
)
|
||||
|
||||
|
||||
class TransferOrderAllocateTest(OrderTest):
|
||||
"""Unit tests for allocating stock items against a TransferOrder."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""Init routine for this unit test class."""
|
||||
super().setUpTestData()
|
||||
|
||||
def setUp(self):
|
||||
"""Init routines for this unit testing class."""
|
||||
super().setUp()
|
||||
|
||||
self.assignRole('transfer_order.add')
|
||||
|
||||
self.url = reverse('api-transfer-order-allocate', kwargs={'pk': 1})
|
||||
self.url_serialized = reverse(
|
||||
'api-transfer-order-allocate-serials', kwargs={'pk': 1}
|
||||
)
|
||||
|
||||
self.order = models.TransferOrder.objects.get(pk=1)
|
||||
|
||||
# Create some line items for this transfer order
|
||||
parts = Part.objects.exclude(virtual=True)
|
||||
|
||||
for part in parts:
|
||||
# Create a new line item
|
||||
models.TransferOrderLineItem.objects.create(
|
||||
order=self.order, part=part, quantity=5
|
||||
)
|
||||
|
||||
# Ensure we have stock!
|
||||
StockItem.objects.create(part=part, quantity=100)
|
||||
|
||||
# Create a new shipment against this TransferOrder
|
||||
# self.shipment = models.TransferOrderShipment.objects.create(order=self.order)
|
||||
|
||||
def test_invalid(self):
|
||||
"""Test POST with invalid data."""
|
||||
# No data
|
||||
response = self.post(self.url, {}, expected_code=400)
|
||||
|
||||
self.assertIn('This field is required', str(response.data['items']))
|
||||
|
||||
# Test with a single line items
|
||||
line = self.order.lines.first()
|
||||
part = line.part
|
||||
|
||||
# Valid stock_item, but quantity is invalid
|
||||
data = {
|
||||
'items': [
|
||||
{
|
||||
'line_item': line.pk,
|
||||
'stock_item': part.stock_items.last().pk,
|
||||
'quantity': 0,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
response = self.post(self.url, data, expected_code=400)
|
||||
|
||||
self.assertIn('Quantity must be positive', str(response.data['items']))
|
||||
|
||||
# Valid stock item, too much quantity
|
||||
data['items'][0]['quantity'] = 250
|
||||
|
||||
response = self.post(self.url, data, expected_code=400)
|
||||
|
||||
self.assertIn('Available quantity (100) exceeded', str(response.data['items']))
|
||||
|
||||
def test_allocate(self):
|
||||
"""Test that the allocation endpoint acts as expected, when provided with valid data!"""
|
||||
# First, check that there are no line items allocated against this TransferOrder
|
||||
self.assertEqual(self.order.stock_allocations.count(), 0)
|
||||
|
||||
data = {'items': []}
|
||||
|
||||
for line in self.order.lines.all():
|
||||
for stock_item in line.part.stock_items.filter(quantity__gt=5):
|
||||
# Find a non-serialized stock item to allocate
|
||||
if not stock_item.serialized:
|
||||
break
|
||||
|
||||
# Fully-allocate each line
|
||||
data['items'].append({
|
||||
'line_item': line.pk,
|
||||
'stock_item': stock_item.pk,
|
||||
'quantity': 5,
|
||||
})
|
||||
|
||||
self.post(self.url, data, expected_code=201)
|
||||
|
||||
# There should have been 1 stock item allocated against each line item
|
||||
n_lines = self.order.lines.count()
|
||||
|
||||
self.assertEqual(self.order.stock_allocations.count(), n_lines)
|
||||
|
||||
for line in self.order.lines.all():
|
||||
self.assertEqual(line.allocations.count(), 1)
|
||||
|
||||
def test_allocate_serials(self):
|
||||
"""Test that the allocation endpoint acts as expected, when provided with serials."""
|
||||
self.assertEqual(self.order.stock_allocations.count(), 0)
|
||||
|
||||
trackable_lines = self.order.lines.filter(part__trackable=True)
|
||||
for line in trackable_lines:
|
||||
stock_item = (
|
||||
line.part.stock_items
|
||||
.exclude(serial=None)
|
||||
.filter(StockItem.IN_STOCK_FILTER)
|
||||
.first()
|
||||
)
|
||||
|
||||
# Allocate this serialized item to the transfer order
|
||||
data = {
|
||||
'line_item': line.pk,
|
||||
'quantity': 1,
|
||||
'serial_numbers': stock_item.serial,
|
||||
}
|
||||
|
||||
self.post(self.url_serialized, data, expected_code=201)
|
||||
|
||||
# There should have been 1 stock item allocated against each line item
|
||||
n_lines = trackable_lines.count()
|
||||
self.assertEqual(self.order.stock_allocations.count(), n_lines)
|
||||
|
||||
for line in trackable_lines.all():
|
||||
self.assertEqual(line.allocations.count(), 1)
|
||||
|
||||
def test_allocate_variant(self):
|
||||
"""Test that the allocation endpoint acts as expected, when provided with variant."""
|
||||
# First, check that there are no line items allocated against this TransferOrder
|
||||
self.assertEqual(self.order.stock_allocations.count(), 0)
|
||||
|
||||
data = {'items': []}
|
||||
|
||||
def check_template(line_item):
|
||||
return line_item.part.is_template
|
||||
|
||||
for line in filter(check_template, self.order.lines.all()):
|
||||
stock_item: Optional[StockItem] = None
|
||||
|
||||
stock_item = None
|
||||
|
||||
# Allocate a matching variant
|
||||
parts: list[Part] = (
|
||||
Part.objects
|
||||
.exclude(virtual=True)
|
||||
.exclude(is_template=True)
|
||||
.filter(variant_of=line.part.pk)
|
||||
)
|
||||
# if we don't have a matching variant, continue
|
||||
if not parts.exists():
|
||||
continue
|
||||
for part in parts:
|
||||
# ensure we have the quantity necessary to allocate
|
||||
if not part.stock_items.filter(quantity__gt=5).exists():
|
||||
continue
|
||||
|
||||
stock_item = part.stock_items.last()
|
||||
|
||||
for item in part.stock_items.filter(quantity__gt=5):
|
||||
if item.serialized:
|
||||
continue
|
||||
|
||||
stock_item = item
|
||||
break
|
||||
|
||||
if stock_item is not None:
|
||||
break
|
||||
|
||||
if stock_item is None:
|
||||
raise self.fail('No stock item found for part') # pragma: no cover
|
||||
|
||||
# Fully-allocate each line
|
||||
data['items'].append({
|
||||
'line_item': line.pk,
|
||||
'stock_item': stock_item.pk,
|
||||
'quantity': 5,
|
||||
})
|
||||
|
||||
self.post(self.url, data, expected_code=201)
|
||||
|
||||
# At least one item should be allocated, and all should be variants
|
||||
self.assertGreater(self.order.stock_allocations.count(), 0)
|
||||
for allocation in self.order.stock_allocations.all():
|
||||
self.assertNotEqual(allocation.item.part.pk, allocation.line.part.pk)
|
||||
|
||||
def test_output_options(self):
|
||||
"""Test the various output options for the SalesOrderAllocation detail endpoint."""
|
||||
self.run_output_test(
|
||||
reverse('api-transfer-order-allocation-list'),
|
||||
['part_detail', 'item_detail', 'order_detail', 'location_detail'],
|
||||
assert_subset=True,
|
||||
)
|
||||
|
||||
@@ -22,6 +22,13 @@ def generate_next_return_order_reference():
|
||||
return ReturnOrder.generate_reference()
|
||||
|
||||
|
||||
def generate_next_transfer_order_reference():
|
||||
"""Generate the next available TransferOrder reference."""
|
||||
from order.models import TransferOrder
|
||||
|
||||
return TransferOrder.generate_reference()
|
||||
|
||||
|
||||
def validate_sales_order_reference_pattern(pattern):
|
||||
"""Validate the SalesOrder reference 'pattern' setting."""
|
||||
from order.models import SalesOrder
|
||||
@@ -62,3 +69,17 @@ def validate_return_order_reference(value):
|
||||
from order.models import ReturnOrder
|
||||
|
||||
ReturnOrder.validate_reference_field(value)
|
||||
|
||||
|
||||
def validate_transfer_order_reference_pattern(pattern):
|
||||
"""Validate the TransferOrder reference 'pattern' setting."""
|
||||
from order.models import TransferOrder
|
||||
|
||||
TransferOrder.validate_reference_pattern(pattern)
|
||||
|
||||
|
||||
def validate_transfer_order_reference(value):
|
||||
"""Validate that the ReturnOrder reference field matches the required pattern."""
|
||||
from order.models import TransferOrder
|
||||
|
||||
TransferOrder.validate_reference_field(value)
|
||||
|
||||
@@ -37,7 +37,11 @@ from sql_util.utils import SubquerySum
|
||||
import part.models
|
||||
import stock.models
|
||||
from build.status_codes import BuildStatusGroups
|
||||
from order.status_codes import PurchaseOrderStatusGroups, SalesOrderStatusGroups
|
||||
from order.status_codes import (
|
||||
PurchaseOrderStatusGroups,
|
||||
SalesOrderStatusGroups,
|
||||
TransferOrderStatusGroups,
|
||||
)
|
||||
|
||||
|
||||
def annotate_in_production_quantity(reference: str = '') -> QuerySet:
|
||||
@@ -274,6 +278,40 @@ def annotate_sales_order_allocations(reference: str = '', location=None) -> Quer
|
||||
)
|
||||
|
||||
|
||||
def annotate_transfer_order_allocations(reference: str = '', location=None) -> QuerySet:
|
||||
"""Annotate the total quantity of each part allocated to transfer orders.
|
||||
|
||||
- This function calculates the total part quantity allocated to open transfer orders"
|
||||
- Finds all transfer order allocations for each part (using the provided filter)
|
||||
- Aggregates the 'allocated quantity' for each relevant transfer order allocation item
|
||||
|
||||
Arguments:
|
||||
reference: The relationship reference of the part from the current model
|
||||
location: If provided, only allocated stock items from this location are considered
|
||||
"""
|
||||
# Order filter only returns open orders
|
||||
order_filter = Q(line__order__status__in=TransferOrderStatusGroups.OPEN)
|
||||
|
||||
if location is not None:
|
||||
# Filter by location (including any child locations)
|
||||
|
||||
order_filter &= Q(
|
||||
item__location__tree_id=location.tree_id,
|
||||
item__location__lft__gte=location.lft,
|
||||
item__location__rght__lte=location.rght,
|
||||
item__location__level__gte=location.level,
|
||||
)
|
||||
|
||||
return Coalesce(
|
||||
SubquerySum(
|
||||
f'{reference}stock_items__transfer_order_allocations__quantity',
|
||||
filter=order_filter,
|
||||
),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
|
||||
|
||||
def variant_stock_query(reference: str = '', filter: Optional[Q] = None) -> QuerySet:
|
||||
"""Create a queryset to retrieve all stock items for variant parts under the specified part.
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ from order.status_codes import (
|
||||
PurchaseOrderStatus,
|
||||
PurchaseOrderStatusGroups,
|
||||
SalesOrderStatusGroups,
|
||||
TransferOrderStatusGroups,
|
||||
)
|
||||
from stock import models as StockModels
|
||||
|
||||
@@ -1768,8 +1769,50 @@ class Part(
|
||||
|
||||
return query['total']
|
||||
|
||||
def transfer_order_allocations(self, **kwargs):
|
||||
"""Return all transfer-order-allocation objects which allocate this part to a TransferOrder."""
|
||||
include_variants = kwargs.get('include_variants', True)
|
||||
|
||||
queryset = OrderModels.TransferOrderAllocation.objects.all()
|
||||
|
||||
if include_variants:
|
||||
# Include allocations for all variants
|
||||
variants = self.get_descendants(include_self=True)
|
||||
queryset = queryset.filter(item__part__in=variants)
|
||||
else:
|
||||
# Only look at this part
|
||||
queryset = queryset.filter(item__part=self)
|
||||
|
||||
# Default behaviour is to only return *pending* allocations
|
||||
pending = kwargs.get('pending', True)
|
||||
|
||||
if pending is True:
|
||||
# Look only for 'open' orders
|
||||
queryset = queryset.filter(
|
||||
line__order__status__in=TransferOrderStatusGroups.OPEN
|
||||
)
|
||||
elif pending is False:
|
||||
# Look only for 'closed' orders
|
||||
queryset = queryset.exclude(
|
||||
line__order__status__in=TransferOrderStatusGroups.OPEN
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
def transfer_order_allocation_count(self, **kwargs):
|
||||
"""Return the total quantity of this part allocated to transfer orders."""
|
||||
query = self.transfer_order_allocations(**kwargs).aggregate(
|
||||
total=Coalesce(
|
||||
Sum('quantity', output_field=models.DecimalField()),
|
||||
0,
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
)
|
||||
|
||||
return query['total']
|
||||
|
||||
def allocation_count(self, **kwargs):
|
||||
"""Return the total quantity of stock allocated for this part, against both build orders and sales orders."""
|
||||
"""Return the total quantity of stock allocated for this part, against build orders, sales orders, and transfer orders."""
|
||||
if self.id is None:
|
||||
# If this instance has not been saved, foreign-key lookups will fail
|
||||
return 0
|
||||
@@ -1777,6 +1820,8 @@ class Part(
|
||||
return sum([
|
||||
self.build_order_allocation_count(**kwargs),
|
||||
self.sales_order_allocation_count(**kwargs),
|
||||
# For now, stock allocated to a transfer order will not impact its availability
|
||||
# self.transfer_order_allocation_count(**kwargs),
|
||||
])
|
||||
|
||||
def stock_entries(
|
||||
|
||||
@@ -735,6 +735,8 @@ class PartSerializer(
|
||||
ordering=part_filters.annotate_on_order_quantity(),
|
||||
in_stock=part_filters.annotate_total_stock(),
|
||||
allocated_to_sales_orders=part_filters.annotate_sales_order_allocations(),
|
||||
# NOTE: for now, decided that allocations to Transfer Orders don't reduce available stock
|
||||
# allocated_to_transfer_orders=part_filters.annotate_transfer_order_allocations(),
|
||||
allocated_to_build_orders=part_filters.annotate_build_order_allocations(),
|
||||
)
|
||||
|
||||
@@ -759,6 +761,8 @@ class PartSerializer(
|
||||
ExpressionWrapper(
|
||||
F('total_in_stock')
|
||||
- F('allocated_to_sales_orders')
|
||||
# NOTE: for now, decided that allocations to Transfer Orders don't reduce available stock
|
||||
# - F('allocated_to_transfer_orders'),
|
||||
- F('allocated_to_build_orders'),
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
@@ -768,6 +772,8 @@ class PartSerializer(
|
||||
)
|
||||
|
||||
# Annotate with the total 'required for builds' quantity
|
||||
# NOTE: for now, we don't consider transfer orders for required quantities
|
||||
# and they are assumed to operate on stock that already exists.
|
||||
queryset = queryset.annotate(
|
||||
required_for_build_orders=part_filters.annotate_build_order_requirements(),
|
||||
required_for_sales_orders=part_filters.annotate_sales_order_requirements(),
|
||||
@@ -1907,6 +1913,7 @@ class BomItemSerializer(
|
||||
'sub_part__stock_items',
|
||||
'sub_part__stock_items__allocations',
|
||||
'sub_part__stock_items__sales_order_allocations',
|
||||
'sub_part__stock_items__transfer_order_allocations',
|
||||
)
|
||||
|
||||
# Annotate with the 'total pricing' information based on unit pricing and quantity
|
||||
|
||||
@@ -229,6 +229,13 @@ class ReportConfig(AppConfig):
|
||||
'model_type': 'returnorder',
|
||||
'filename_pattern': 'ReturnOrder-{{ reference }}.pdf',
|
||||
},
|
||||
{
|
||||
'file': 'inventree_transfer_order_report.html',
|
||||
'name': 'InvenTree Transfer Order',
|
||||
'description': 'Sample transfer order report',
|
||||
'model_type': 'transferorder',
|
||||
'filename_pattern': 'TransferOrder-{{ reference }}.pdf',
|
||||
},
|
||||
{
|
||||
'file': 'inventree_test_report.html',
|
||||
'name': 'InvenTree Test Report',
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
{% extends "report/inventree_order_report_base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load report %}
|
||||
{% load barcode %}
|
||||
{% load inventree_extras %}
|
||||
{% load markdownify %}
|
||||
|
||||
{% block header_content %}
|
||||
|
||||
<div class='header-right'>
|
||||
<h3>{% trans "Transfer Order" %} {{ prefix }}{{ reference }}</h3>
|
||||
{{ order.take_from.pathstring }} → {{ order.destination.pathstring }}
|
||||
</div>
|
||||
|
||||
{% endblock header_content %}
|
||||
|
||||
{% block page_content %}
|
||||
|
||||
<h3>{% trans "Line Items" %}</h3>
|
||||
|
||||
<table class='table table-striped table-condensed'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Part" %}</th>
|
||||
<th>{% trans "Reference" %}</th>
|
||||
<th>{% trans "Quantity" %}</th>
|
||||
<th>{% trans "Transferred" %}</th>
|
||||
<th>{% trans "Note" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for line in lines.all %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class='thumb-container'>
|
||||
<img src='{% part_image line.part height=240 %}' alt='{% trans "Part image" %}' class='part-thumb'>
|
||||
</div>
|
||||
<div class='part-text'>
|
||||
{{ line.part.full_name }}
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ line.reference }}</td>
|
||||
<td>{% decimal line.quantity %}</td>
|
||||
<td>{% decimal line.transferred %}</td>
|
||||
<td>{{ line.notes }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% endblock page_content %}
|
||||
@@ -53,11 +53,12 @@ from InvenTree.mixins import (
|
||||
RetrieveUpdateDestroyAPI,
|
||||
SerializerContextMixin,
|
||||
)
|
||||
from order.models import PurchaseOrder, ReturnOrder, SalesOrder
|
||||
from order.models import PurchaseOrder, ReturnOrder, SalesOrder, TransferOrder
|
||||
from order.serializers import (
|
||||
PurchaseOrderSerializer,
|
||||
ReturnOrderSerializer,
|
||||
SalesOrderSerializer,
|
||||
TransferOrderSerializer,
|
||||
)
|
||||
from part.models import BomItem, Part, PartCategory
|
||||
from part.serializers import PartBriefSerializer
|
||||
@@ -672,13 +673,17 @@ class StockFilter(FilterSet):
|
||||
def filter_allocated(self, queryset, name, value):
|
||||
"""Filter by whether or not the stock item is 'allocated'."""
|
||||
if str2bool(value):
|
||||
# Filter StockItem with either build allocations or sales order allocations
|
||||
# Filter StockItem with either build allocations or transfer order allocations or sales order allocations
|
||||
return queryset.filter(
|
||||
Q(sales_order_allocations__isnull=False) | Q(allocations__isnull=False)
|
||||
Q(sales_order_allocations__isnull=False)
|
||||
| Q(transfer_order_allocations__isnull=False)
|
||||
| Q(allocations__isnull=False)
|
||||
).distinct()
|
||||
# Filter StockItem without build allocations or sales order allocations
|
||||
# Filter StockItem without build allocations or transfer order allocations or sales order allocations
|
||||
return queryset.filter(
|
||||
Q(sales_order_allocations__isnull=True) & Q(allocations__isnull=True)
|
||||
Q(sales_order_allocations__isnull=True)
|
||||
& Q(transfer_order_allocations__isnull=True)
|
||||
& Q(allocations__isnull=True)
|
||||
)
|
||||
|
||||
expired = rest_filters.BooleanFilter(label='Expired', method='filter_expired')
|
||||
@@ -1584,6 +1589,7 @@ class StockTrackingList(
|
||||
'purchaseorder': (PurchaseOrder, PurchaseOrderSerializer),
|
||||
'salesorder': (SalesOrder, SalesOrderSerializer),
|
||||
'returnorder': (ReturnOrder, ReturnOrderSerializer),
|
||||
'transferorder': (TransferOrder, TransferOrderSerializer),
|
||||
'buildorder': (Build, BuildSerializer),
|
||||
'item': (StockItem, StockSerializers.StockItemSerializer),
|
||||
'stockitem': (StockItem, StockSerializers.StockItemSerializer),
|
||||
|
||||
@@ -47,6 +47,7 @@ from InvenTree.status_codes import (
|
||||
StockStatus,
|
||||
StockStatusGroups,
|
||||
)
|
||||
from order.status_codes import TransferOrderStatusGroups
|
||||
from part import models as PartModels
|
||||
from plugin.events import trigger_event
|
||||
from stock.events import StockEvents
|
||||
@@ -1535,7 +1536,7 @@ class StockItem(
|
||||
item.save(add_note=False)
|
||||
|
||||
def is_allocated(self):
|
||||
"""Return True if this StockItem is allocated to a SalesOrder or a Build."""
|
||||
"""Return True if this StockItem is allocated to a SalesOrder, TransferOrder, or a Build."""
|
||||
return self.allocation_count() > 0
|
||||
|
||||
def build_allocation_count(self, **kwargs):
|
||||
@@ -1595,12 +1596,48 @@ class StockItem(
|
||||
|
||||
return total
|
||||
|
||||
def get_transfer_order_allocations(self, active=True, **kwargs):
|
||||
"""Return a queryset for TransferOrderAllocations against this StockItem, with optional filters.
|
||||
|
||||
Arguments:
|
||||
active: Filter by 'active' status of the allocation
|
||||
"""
|
||||
query = self.transfer_order_allocations.all()
|
||||
|
||||
if filter_allocations := kwargs.get('filter_allocations'):
|
||||
query = query.filter(**filter_allocations)
|
||||
|
||||
if exclude_allocations := kwargs.get('exclude_allocations'):
|
||||
query = query.exclude(**exclude_allocations)
|
||||
|
||||
if active is True:
|
||||
query = query.filter(line__order__status__in=TransferOrderStatusGroups.OPEN)
|
||||
elif active is False:
|
||||
query = query.exclude(
|
||||
line__order__status__in=TransferOrderStatusGroups.OPEN
|
||||
)
|
||||
|
||||
return query
|
||||
|
||||
def transfer_order_allocation_count(self, active=True, **kwargs):
|
||||
"""Return the total quantity allocated to TransferOrders."""
|
||||
query = self.get_transfer_order_allocations(active=active, **kwargs)
|
||||
query = query.aggregate(q=Coalesce(Sum('quantity'), Decimal(0)))
|
||||
|
||||
total = query['q']
|
||||
|
||||
if total is None:
|
||||
total = Decimal(0)
|
||||
|
||||
return total
|
||||
|
||||
def allocation_count(self):
|
||||
"""Return the total quantity allocated to builds or orders."""
|
||||
bo = self.build_allocation_count()
|
||||
so = self.sales_order_allocation_count()
|
||||
to = self.transfer_order_allocation_count()
|
||||
|
||||
return bo + so
|
||||
return bo + so + to
|
||||
|
||||
def unallocated_quantity(self):
|
||||
"""Return the quantity of this StockItem which is *not* allocated."""
|
||||
@@ -2330,6 +2367,10 @@ class StockItem(
|
||||
|
||||
deltas = {'stockitem': self.pk}
|
||||
|
||||
transferorder = kwargs.pop('transferorder', None)
|
||||
if transferorder:
|
||||
deltas['transferorder'] = transferorder.pk
|
||||
|
||||
# Optional fields which can be supplied in a 'move' call
|
||||
for field in StockItem.optional_transfer_fields():
|
||||
if field in kwargs:
|
||||
@@ -2478,6 +2519,10 @@ class StockItem(
|
||||
)
|
||||
tracking_info['old_status_logical'] = old_status_logical
|
||||
|
||||
transferorder = kwargs.pop('transferorder', None)
|
||||
if transferorder:
|
||||
tracking_info['transferorder'] = transferorder.pk
|
||||
|
||||
# Optional fields which can be supplied in a 'move' call
|
||||
for field in StockItem.optional_transfer_fields():
|
||||
if field in kwargs:
|
||||
@@ -2717,6 +2762,10 @@ class StockItem(
|
||||
setattr(self, field, kwargs[field])
|
||||
deltas[field] = kwargs[field]
|
||||
|
||||
transferorder = kwargs.pop('transferorder', None)
|
||||
if transferorder:
|
||||
deltas['transferorder'] = transferorder.pk
|
||||
|
||||
self.save(add_note=False)
|
||||
|
||||
self.add_tracking_entry(
|
||||
|
||||
@@ -520,6 +520,8 @@ class StockItemSerializer(
|
||||
allocated=Coalesce(
|
||||
SubquerySum('sales_order_allocations__quantity'), Decimal(0)
|
||||
)
|
||||
# For now, stock allocated to a transfer order will not impact its availability
|
||||
# + Coalesce(SubquerySum('transfer_order_allocations__quantity'), Decimal(0))
|
||||
+ Coalesce(SubquerySum('allocations__quantity'), Decimal(0))
|
||||
)
|
||||
|
||||
@@ -1390,6 +1392,10 @@ class StockAssignmentItemSerializer(serializers.Serializer):
|
||||
if item.sales_order_allocations.count() > 0:
|
||||
raise ValidationError(_('Item is allocated to a sales order'))
|
||||
|
||||
# The item must not be allocated to a transfer order
|
||||
if item.transfer_order_allocations.count() > 0:
|
||||
raise ValidationError(_('Item is allocated to a transfer order'))
|
||||
|
||||
# The item must not be allocated to a build order
|
||||
if item.allocations.count() > 0:
|
||||
raise ValidationError(_('Item is allocated to a build order'))
|
||||
|
||||
@@ -20,6 +20,7 @@ _roles = {
|
||||
'purchase_order': 'Role Purchase Orders',
|
||||
'sales_order': 'Role Sales Orders',
|
||||
'return_order': 'Role Return Orders',
|
||||
'transfer_order': 'Role Transfer Orders',
|
||||
}
|
||||
_methods = {'view': 'GET', 'add': 'POST', 'change': 'PUT / PATCH', 'delete': 'DELETE'}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ class RuleSetEnum(StringEnum):
|
||||
PURCHASE_ORDER = 'purchase_order'
|
||||
SALES_ORDER = 'sales_order'
|
||||
RETURN_ORDER = 'return_order'
|
||||
TRANSFER_ORDER = 'transfer_order'
|
||||
|
||||
|
||||
# This is a list of all the ruleset choices available in the system.
|
||||
@@ -34,6 +35,7 @@ RULESET_CHOICES = [
|
||||
(RuleSetEnum.PURCHASE_ORDER, _('Purchase Orders')),
|
||||
(RuleSetEnum.SALES_ORDER, _('Sales Orders')),
|
||||
(RuleSetEnum.RETURN_ORDER, _('Return Orders')),
|
||||
(RuleSetEnum.TRANSFER_ORDER, _('Transfer Orders')),
|
||||
]
|
||||
|
||||
# Ruleset names available in the system.
|
||||
@@ -161,6 +163,11 @@ def get_ruleset_models() -> dict:
|
||||
'order_returnorderlineitem',
|
||||
'order_returnorderextraline',
|
||||
],
|
||||
RuleSetEnum.TRANSFER_ORDER: [
|
||||
'order_transferorder',
|
||||
'order_transferorderallocation',
|
||||
'order_transferorderlineitem',
|
||||
],
|
||||
}
|
||||
|
||||
if settings.SITE_MULTI:
|
||||
|
||||
@@ -199,6 +199,17 @@ export enum ApiEndpoints {
|
||||
return_order_line_list = 'order/ro-line/',
|
||||
return_order_extra_line_list = 'order/ro-extra-line/',
|
||||
|
||||
transfer_order_list = 'order/transfer-order/',
|
||||
transfer_order_issue = 'order/transfer-order/:id/issue/',
|
||||
transfer_order_hold = 'order/transfer-order/:id/hold/',
|
||||
transfer_order_cancel = 'order/transfer-order/:id/cancel/',
|
||||
transfer_order_complete = 'order/transfer-order/:id/complete/',
|
||||
transfer_order_allocate = 'order/transfer-order/:id/allocate/',
|
||||
transfer_order_allocate_serials = 'order/transfer-order/:id/allocate-serials/',
|
||||
|
||||
transfer_order_line_list = 'order/transfer-order-line/',
|
||||
transfer_order_allocation_list = 'order/transfer-order-allocation/',
|
||||
|
||||
// Template API endpoints
|
||||
label_list = 'label/template/',
|
||||
label_print = 'label/print/',
|
||||
|
||||
@@ -207,6 +207,22 @@ export const ModelInformationDict: ModelDict = {
|
||||
api_endpoint: ApiEndpoints.return_order_line_list,
|
||||
icon: 'return_orders'
|
||||
},
|
||||
transferorder: {
|
||||
label: () => t`Transfer Order`,
|
||||
label_multiple: () => t`Transfer Orders`,
|
||||
url_overview: '/stock/location/index/transfer-orders',
|
||||
url_detail: '/stock/transfer-order/:pk/',
|
||||
api_endpoint: ApiEndpoints.transfer_order_list,
|
||||
admin_url: '/order/transferorder/',
|
||||
supports_barcode: true,
|
||||
icon: 'transfer_orders'
|
||||
},
|
||||
transferorderlineitem: {
|
||||
label: () => t`Transfer Order Line Item`,
|
||||
label_multiple: () => t`Transfer Order Line Items`,
|
||||
api_endpoint: ApiEndpoints.transfer_order_line_list,
|
||||
icon: 'transfer-orders'
|
||||
},
|
||||
address: {
|
||||
label: () => t`Address`,
|
||||
label_multiple: () => t`Addresses`,
|
||||
|
||||
@@ -24,6 +24,8 @@ export enum ModelType {
|
||||
salesordershipment = 'salesordershipment',
|
||||
returnorder = 'returnorder',
|
||||
returnorderlineitem = 'returnorderlineitem',
|
||||
transferorder = 'transferorder',
|
||||
transferorderlineitem = 'transferorderlineitem',
|
||||
importsession = 'importsession',
|
||||
address = 'address',
|
||||
contact = 'contact',
|
||||
|
||||
@@ -11,6 +11,7 @@ export enum UserRoles {
|
||||
part_category = 'part_category',
|
||||
purchase_order = 'purchase_order',
|
||||
return_order = 'return_order',
|
||||
transfer_order = 'transfer_order',
|
||||
sales_order = 'sales_order',
|
||||
stock = 'stock',
|
||||
stock_location = 'stock_location'
|
||||
@@ -40,6 +41,8 @@ export function userRoleLabel(role: UserRoles): string {
|
||||
return t`Purchase Orders`;
|
||||
case UserRoles.return_order:
|
||||
return t`Return Orders`;
|
||||
case UserRoles.transfer_order:
|
||||
return t`Transfer Orders`;
|
||||
case UserRoles.sales_order:
|
||||
return t`Sales Orders`;
|
||||
case UserRoles.stock:
|
||||
|
||||
@@ -51,7 +51,9 @@ import {
|
||||
RenderReturnOrder,
|
||||
RenderReturnOrderLineItem,
|
||||
RenderSalesOrder,
|
||||
RenderSalesOrderShipment
|
||||
RenderSalesOrderShipment,
|
||||
RenderTransferOrder,
|
||||
RenderTransferOrderLineItem
|
||||
} from './Order';
|
||||
import { RenderPart, RenderPartCategory, RenderPartTestTemplate } from './Part';
|
||||
import { RenderPlugin } from './Plugin';
|
||||
@@ -87,6 +89,8 @@ export const RendererLookup: ModelRendererDict = {
|
||||
[ModelType.returnorderlineitem]: RenderReturnOrderLineItem,
|
||||
[ModelType.salesorder]: RenderSalesOrder,
|
||||
[ModelType.salesordershipment]: RenderSalesOrderShipment,
|
||||
[ModelType.transferorder]: RenderTransferOrder,
|
||||
[ModelType.transferorderlineitem]: RenderTransferOrderLineItem,
|
||||
[ModelType.stocklocation]: RenderStockLocation,
|
||||
[ModelType.stocklocationtype]: RenderStockLocationType,
|
||||
[ModelType.stockitem]: RenderStockItem,
|
||||
|
||||
@@ -123,3 +123,46 @@ export function RenderSalesOrderShipment({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline rendering of a single TransferOrder instance
|
||||
*/
|
||||
export function RenderTransferOrder(
|
||||
props: Readonly<InstanceRenderInterface>
|
||||
): ReactNode {
|
||||
const { instance } = props;
|
||||
|
||||
return (
|
||||
<RenderInlineModel
|
||||
{...props}
|
||||
primary={instance.reference}
|
||||
secondary={instance.description}
|
||||
suffix={StatusRenderer({
|
||||
status: instance.status_custom_key,
|
||||
type: ModelType.transferorder
|
||||
})}
|
||||
url={
|
||||
props.link
|
||||
? getDetailUrl(ModelType.transferorder, instance.pk)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function RenderTransferOrderLineItem(
|
||||
props: Readonly<InstanceRenderInterface>
|
||||
): ReactNode {
|
||||
const { instance } = props;
|
||||
|
||||
return (
|
||||
<RenderInlineModel
|
||||
{...props}
|
||||
primary={instance.reference}
|
||||
suffix={StatusRenderer({
|
||||
status: instance.outcome,
|
||||
type: ModelType.transferorderlineitem
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ export const statusCodeList: Record<string, ModelType> = {
|
||||
PurchaseOrderStatus: ModelType.purchaseorder,
|
||||
ReturnOrderStatus: ModelType.returnorder,
|
||||
ReturnOrderLineStatus: ModelType.returnorderlineitem,
|
||||
TransferOrderStatus: ModelType.transferorder,
|
||||
TransferOrderLineStatus: ModelType.transferorderlineitem,
|
||||
SalesOrderStatus: ModelType.salesorder,
|
||||
StockHistoryCode: ModelType.stockhistory,
|
||||
StockStatus: ModelType.stockitem,
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import { ApiEndpoints, ModelType, ProgressBar, apiUrl } from '@lib/index';
|
||||
import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Table } from '@mantine/core';
|
||||
import { IconCalendar, IconUsers } from '@tabler/icons-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import RemoveRowButton from '../components/buttons/RemoveRowButton';
|
||||
import { StandaloneField } from '../components/forms/StandaloneField';
|
||||
import type { TableFieldRowProps } from '../components/forms/fields/TableField';
|
||||
import { useCreateApiFormModal } from '../hooks/UseForm';
|
||||
import { useGlobalSettingsState } from '../states/SettingsStates';
|
||||
import { RenderPartColumn } from '../tables/ColumnRenderers';
|
||||
|
||||
export function useTransferOrderFields({
|
||||
duplicateOrderId
|
||||
}: {
|
||||
duplicateOrderId?: number;
|
||||
}): ApiFormFieldSet {
|
||||
const globalSettings = useGlobalSettingsState();
|
||||
|
||||
return useMemo(() => {
|
||||
const fields: ApiFormFieldSet = {
|
||||
reference: {},
|
||||
description: {},
|
||||
project_code: {},
|
||||
start_date: {
|
||||
icon: <IconCalendar />
|
||||
},
|
||||
target_date: {
|
||||
icon: <IconCalendar />
|
||||
},
|
||||
take_from: {},
|
||||
destination: {
|
||||
filters: {
|
||||
structural: false
|
||||
}
|
||||
},
|
||||
consume: {},
|
||||
link: {},
|
||||
responsible: {
|
||||
filters: {
|
||||
is_active: true
|
||||
},
|
||||
icon: <IconUsers />
|
||||
}
|
||||
};
|
||||
|
||||
// Order duplication fields
|
||||
if (!!duplicateOrderId) {
|
||||
fields.duplicate = {
|
||||
children: {
|
||||
order_id: {
|
||||
hidden: true,
|
||||
value: duplicateOrderId
|
||||
},
|
||||
copy_lines: {},
|
||||
// Transfer Orders don't have extra lines for now...
|
||||
copy_extra_lines: { hidden: true, value: false }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!globalSettings.isSet('PROJECT_CODES_ENABLED', true)) {
|
||||
delete fields.project_code;
|
||||
}
|
||||
|
||||
return fields;
|
||||
}, [duplicateOrderId, globalSettings]);
|
||||
}
|
||||
|
||||
export function useTransferOrderLineItemFields({
|
||||
orderId,
|
||||
create
|
||||
}: {
|
||||
orderId?: number;
|
||||
create?: boolean;
|
||||
}): ApiFormFieldSet {
|
||||
return useMemo(() => {
|
||||
const fields: ApiFormFieldSet = {
|
||||
order: {
|
||||
filters: {},
|
||||
disabled: true
|
||||
},
|
||||
part: {
|
||||
filters: {
|
||||
active: true,
|
||||
virtual: false
|
||||
}
|
||||
},
|
||||
reference: {},
|
||||
quantity: {},
|
||||
project_code: {
|
||||
description: t`Select project code for this line item`
|
||||
},
|
||||
target_date: {},
|
||||
notes: {},
|
||||
link: {}
|
||||
};
|
||||
|
||||
return fields;
|
||||
}, [orderId, create]);
|
||||
}
|
||||
|
||||
function TransferOrderAllocateLineRow({
|
||||
props,
|
||||
record,
|
||||
sourceLocation
|
||||
}: Readonly<{
|
||||
props: TableFieldRowProps;
|
||||
record: any;
|
||||
sourceLocation?: number | null;
|
||||
}>) {
|
||||
// Statically defined field for selecting the stock item
|
||||
const stockItemField: ApiFormFieldType = useMemo(() => {
|
||||
return {
|
||||
field_type: 'related field',
|
||||
api_url: apiUrl(ApiEndpoints.stock_item_list),
|
||||
model: ModelType.stockitem,
|
||||
autoFill: true,
|
||||
filters: {
|
||||
available: true,
|
||||
part_detail: true,
|
||||
location_detail: true,
|
||||
location: sourceLocation,
|
||||
cascade: sourceLocation ? true : undefined,
|
||||
part: record.part
|
||||
},
|
||||
value: props.item.stock_item,
|
||||
name: 'stock_item',
|
||||
onValueChange: (value: any, instance: any) => {
|
||||
props.changeFn(props.idx, 'stock_item', value);
|
||||
|
||||
// Update the allocated quantity based on the selected stock item
|
||||
if (instance) {
|
||||
const available = instance.quantity - instance.allocated;
|
||||
const required = record.quantity - record.allocated;
|
||||
|
||||
let quantity = props.item?.quantity ?? 0;
|
||||
|
||||
quantity = Math.max(quantity, required);
|
||||
quantity = Math.min(quantity, available);
|
||||
|
||||
if (quantity != props.item.quantity) {
|
||||
props.changeFn(props.idx, 'quantity', quantity);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [sourceLocation, record, props]);
|
||||
|
||||
// Statically defined field for selecting the allocation quantity
|
||||
const quantityField: ApiFormFieldType = useMemo(() => {
|
||||
return {
|
||||
field_type: 'number',
|
||||
name: 'quantity',
|
||||
required: true,
|
||||
value: props.item.quantity,
|
||||
onValueChange: (value: any) => {
|
||||
props.changeFn(props.idx, 'quantity', value);
|
||||
}
|
||||
};
|
||||
}, [props]);
|
||||
|
||||
return (
|
||||
<Table.Tr key={`table-row-${props.idx}-${record.pk}`}>
|
||||
<Table.Td>
|
||||
<RenderPartColumn part={record.part_detail} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ProgressBar
|
||||
value={record.allocated}
|
||||
maximum={record.quantity}
|
||||
progressLabel
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<StandaloneField
|
||||
fieldName='stock_item'
|
||||
fieldDefinition={stockItemField}
|
||||
error={props.rowErrors?.stock_item?.message}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<StandaloneField
|
||||
fieldName='quantity'
|
||||
fieldDefinition={quantityField}
|
||||
error={props.rowErrors?.quantity?.message}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAllocateToTransferOrderForm({
|
||||
orderId,
|
||||
sourceLocationId,
|
||||
lineItems,
|
||||
onFormSuccess
|
||||
}: {
|
||||
orderId: number;
|
||||
sourceLocationId?: number;
|
||||
lineItems: any[];
|
||||
onFormSuccess: (response: any) => void;
|
||||
}) {
|
||||
const [sourceLocation, setSourceLocation] = useState<number | null>(
|
||||
sourceLocationId || null
|
||||
);
|
||||
|
||||
const fields: ApiFormFieldSet = useMemo(() => {
|
||||
return {
|
||||
// Non-submitted field to select the source location
|
||||
source_location: {
|
||||
exclude: true,
|
||||
required: false,
|
||||
value: sourceLocationId,
|
||||
field_type: 'related field',
|
||||
api_url: apiUrl(ApiEndpoints.stock_location_list),
|
||||
model: ModelType.stocklocation,
|
||||
label: t`Source Location`,
|
||||
description: t`Select the source location for the stock allocation`,
|
||||
onValueChange: (value: any) => {
|
||||
setSourceLocation(value);
|
||||
}
|
||||
},
|
||||
items: {
|
||||
field_type: 'table',
|
||||
value: [],
|
||||
headers: [
|
||||
{ title: t`Part`, style: { minWidth: '200px' } },
|
||||
{ title: t`Allocated`, style: { minWidth: '200px' } },
|
||||
{ title: t`Stock Item`, style: { width: '100%' } },
|
||||
{ title: t`Quantity`, style: { minWidth: '200px' } },
|
||||
{ title: '', style: { width: '50px' } }
|
||||
],
|
||||
modelRenderer: (row: TableFieldRowProps) => {
|
||||
const record =
|
||||
lineItems.find((item) => item.pk == row.item.line_item) ?? {};
|
||||
|
||||
return (
|
||||
<TransferOrderAllocateLineRow
|
||||
key={`table-row-${row.idx}-${record.pk}`}
|
||||
props={row}
|
||||
record={record}
|
||||
sourceLocation={sourceLocation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [orderId, lineItems, sourceLocation]);
|
||||
|
||||
return useCreateApiFormModal({
|
||||
title: t`Allocate Stock`,
|
||||
url: ApiEndpoints.transfer_order_allocate,
|
||||
pk: orderId,
|
||||
fields: fields,
|
||||
onFormSuccess: onFormSuccess,
|
||||
successMessage: t`Stock items allocated`,
|
||||
size: '80%',
|
||||
initialData: {
|
||||
items: lineItems.map((item) => {
|
||||
return {
|
||||
line_item: item.pk,
|
||||
quantity: 0,
|
||||
stock_item: null
|
||||
};
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useTransferOrderAllocationFields({
|
||||
orderId
|
||||
}: {
|
||||
orderId?: number;
|
||||
}): ApiFormFieldSet {
|
||||
return useMemo(() => {
|
||||
return {
|
||||
item: {
|
||||
// Cannot change item, but display for reference
|
||||
disabled: true
|
||||
},
|
||||
quantity: {}
|
||||
};
|
||||
}, [orderId]);
|
||||
}
|
||||
|
||||
export function useTransferOrderAllocateSerialsFields({
|
||||
itemId,
|
||||
orderId
|
||||
}: {
|
||||
itemId: number;
|
||||
orderId: number;
|
||||
}): ApiFormFieldSet {
|
||||
return useMemo(() => {
|
||||
return {
|
||||
line_item: {
|
||||
value: itemId,
|
||||
hidden: true
|
||||
},
|
||||
quantity: {},
|
||||
serial_numbers: {}
|
||||
};
|
||||
}, [itemId, orderId]);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
IconCancel,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconCircleDashedCheck,
|
||||
IconCircleMinus,
|
||||
IconCirclePlus,
|
||||
IconCircleX,
|
||||
@@ -148,11 +149,13 @@ const icons: InvenTreeIconType = {
|
||||
build_order: IconTools,
|
||||
builds: IconTools,
|
||||
used_in: IconStack2,
|
||||
consume: IconCircleDashedCheck,
|
||||
manufacturers: IconBuildingFactory2,
|
||||
suppliers: IconBuilding,
|
||||
customers: IconBuildingStore,
|
||||
purchase_orders: IconShoppingCart,
|
||||
return_orders: IconTruckReturn,
|
||||
transfer_orders: IconTransfer,
|
||||
sales_orders: IconTruckDelivery,
|
||||
scheduling: IconCalendarStats,
|
||||
scrap: IconCircleX,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
IconQrcode,
|
||||
IconServerCog,
|
||||
IconShoppingCart,
|
||||
IconTransfer,
|
||||
IconTruckDelivery
|
||||
} from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
@@ -363,6 +364,20 @@ export default function SystemSettings() {
|
||||
</Stack>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'transferorders',
|
||||
label: t`Transfer Orders`,
|
||||
icon: <IconTransfer />,
|
||||
content: (
|
||||
<GlobalSettingList
|
||||
keys={[
|
||||
'TRANSFERORDER_ENABLED',
|
||||
'TRANSFERORDER_REFERENCE_PATTERN',
|
||||
'TRANSFERORDER_REQUIRE_RESPONSIBLE'
|
||||
]}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'plugins',
|
||||
label: t`Plugins`,
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
IconStack2,
|
||||
IconTestPipe,
|
||||
IconTools,
|
||||
IconTransfer,
|
||||
IconTruckDelivery,
|
||||
IconTruckReturn,
|
||||
IconVersions
|
||||
@@ -101,6 +102,7 @@ import { RelatedPartTable } from '../../tables/part/RelatedPartTable';
|
||||
import { ReturnOrderTable } from '../../tables/sales/ReturnOrderTable';
|
||||
import { SalesOrderTable } from '../../tables/sales/SalesOrderTable';
|
||||
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
||||
import { TransferOrderTable } from '../../tables/stock/TransferOrderTable';
|
||||
import PartAllocationPanel from './PartAllocationPanel';
|
||||
import PartPricingPanel from './PartPricingPanel';
|
||||
import PartStockHistoryDetail from './PartStockHistoryDetail';
|
||||
@@ -771,6 +773,20 @@ export default function PartDetail() {
|
||||
hidden: !part.assembly || !user.hasViewRole(UserRoles.build),
|
||||
content: part.pk ? <BuildOrderTable partId={part.pk} /> : <Skeleton />
|
||||
},
|
||||
{
|
||||
name: 'transfer_orders',
|
||||
label: t`Transfer Orders`,
|
||||
icon: <IconTransfer />,
|
||||
hidden:
|
||||
part.virtual ||
|
||||
!globalSettings.isSet('TRANSFERORDER_ENABLED') ||
|
||||
!user.hasViewRole(UserRoles.transfer_order),
|
||||
content: part.pk ? (
|
||||
<TransferOrderTable partId={part.pk} />
|
||||
) : (
|
||||
<Skeleton />
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'stocktake',
|
||||
label: t`Stock History`,
|
||||
|
||||
@@ -8,11 +8,13 @@ import type { PanelType } from '@lib/types/Panel';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Group, Skeleton, Stack } from '@mantine/core';
|
||||
import {
|
||||
IconCalendar,
|
||||
IconInfoCircle,
|
||||
IconListDetails,
|
||||
IconPackages,
|
||||
IconSitemap,
|
||||
IconTable
|
||||
IconTable,
|
||||
IconTransfer
|
||||
} from '@tabler/icons-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
@@ -20,6 +22,7 @@ import { api } from '../../App';
|
||||
import { useBarcodeScanDialog } from '../../components/barcodes/BarcodeScanDialog';
|
||||
import AdminButton from '../../components/buttons/AdminButton';
|
||||
import { PrintingActions } from '../../components/buttons/PrintingActions';
|
||||
import OrderCalendar from '../../components/calendar/OrderCalendar';
|
||||
import {
|
||||
type DetailsField,
|
||||
DetailsTable
|
||||
@@ -48,11 +51,14 @@ import {
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
|
||||
import { useUserSettingsState } from '../../states/SettingsStates';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsStates';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { PartListTable } from '../../tables/part/PartTable';
|
||||
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
||||
import StockLocationParametricTable from '../../tables/stock/StockLocationParametricTable';
|
||||
import { StockLocationTable } from '../../tables/stock/StockLocationTable';
|
||||
import TransferOrderParametricTable from '../../tables/stock/TransferOrderParametricTable';
|
||||
import { TransferOrderTable } from '../../tables/stock/TransferOrderTable';
|
||||
|
||||
export default function Stock() {
|
||||
const { id: _id } = useParams();
|
||||
@@ -65,6 +71,7 @@ export default function Stock() {
|
||||
const navigate = useNavigate();
|
||||
const user = useUserState();
|
||||
const settings = useUserSettingsState();
|
||||
const globalSettings = useGlobalSettingsState();
|
||||
|
||||
const [treeOpen, setTreeOpen] = useState(false);
|
||||
|
||||
@@ -169,6 +176,7 @@ export default function Stock() {
|
||||
}, [location, instanceQuery]);
|
||||
|
||||
const [sublocationView, setSublocationView] = useState<string>('table');
|
||||
const [transferOrderView, setTransferOrderView] = useState<string>('table');
|
||||
|
||||
const locationPanels: PanelType[] = useMemo(() => {
|
||||
return [
|
||||
@@ -219,6 +227,42 @@ export default function Stock() {
|
||||
/>
|
||||
)
|
||||
},
|
||||
SegmentedControlPanel({
|
||||
name: 'transfer-orders',
|
||||
label: t`Transfer Orders`,
|
||||
icon: <IconTransfer />,
|
||||
hidden:
|
||||
!user.hasViewRole(UserRoles.transfer_order) ||
|
||||
!globalSettings.isSet('TRANSFERORDER_ENABLED'),
|
||||
selection: transferOrderView,
|
||||
onChange: setTransferOrderView,
|
||||
options: [
|
||||
{
|
||||
value: 'table',
|
||||
label: t`Table View`,
|
||||
icon: <IconTable />,
|
||||
content: <TransferOrderTable />
|
||||
},
|
||||
{
|
||||
value: 'calendar',
|
||||
label: t`Calendar View`,
|
||||
icon: <IconCalendar />,
|
||||
content: (
|
||||
<OrderCalendar
|
||||
model={ModelType.transferorder}
|
||||
role={UserRoles.transfer_order}
|
||||
params={{ outstanding: true }}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'parametric',
|
||||
label: t`Parametric View`,
|
||||
icon: <IconListDetails />,
|
||||
content: <TransferOrderParametricTable />
|
||||
}
|
||||
]
|
||||
}),
|
||||
{
|
||||
name: 'default_parts',
|
||||
label: t`Default Parts`,
|
||||
@@ -240,7 +284,7 @@ export default function Stock() {
|
||||
hidden: !location.pk
|
||||
})
|
||||
];
|
||||
}, [sublocationView, location, id]);
|
||||
}, [sublocationView, transferOrderView, location, id]);
|
||||
|
||||
const editLocation = useEditApiFormModal({
|
||||
url: ApiEndpoints.stock_location_list,
|
||||
|
||||
@@ -88,6 +88,7 @@ import InstalledItemsTable from '../../tables/stock/InstalledItemsTable';
|
||||
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
||||
import StockItemTestResultTable from '../../tables/stock/StockItemTestResultTable';
|
||||
import { StockTrackingTable } from '../../tables/stock/StockTrackingTable';
|
||||
import TransferOrderAllocationTable from '../../tables/stock/TransferOrderAllocationTable';
|
||||
|
||||
export default function StockDetail() {
|
||||
const { id } = useParams();
|
||||
@@ -476,6 +477,13 @@ export default function StockDetail() {
|
||||
return stockitem?.part_detail?.salable;
|
||||
}, [stockitem]);
|
||||
|
||||
const showTransferAllocations: boolean = useMemo(() => {
|
||||
return (
|
||||
!stockitem?.part_detail?.virtual &&
|
||||
globalSettings.isSet('TRANSFERORDER_ENABLED')
|
||||
);
|
||||
}, [stockitem]);
|
||||
|
||||
// API query to determine if this stock item has trackable BOM items
|
||||
const trackedBomItemQuery = useQuery({
|
||||
queryKey: ['tracked-bom-item', stockitem.pk, stockitem.part],
|
||||
@@ -544,11 +552,17 @@ export default function StockDetail() {
|
||||
icon: <IconBookmark />,
|
||||
hidden:
|
||||
!stockitem.in_stock ||
|
||||
(!showSalesAllocations && !showBuildAllocations),
|
||||
(!showSalesAllocations &&
|
||||
!showBuildAllocations &&
|
||||
!showTransferAllocations),
|
||||
content: (
|
||||
<Accordion
|
||||
multiple={true}
|
||||
defaultValue={['buildAllocations', 'salesAllocations']}
|
||||
defaultValue={[
|
||||
'buildAllocations',
|
||||
'salesAllocations',
|
||||
'transferAllocations'
|
||||
]}
|
||||
>
|
||||
{showBuildAllocations && (
|
||||
<Accordion.Item value='buildAllocations' key='buildAllocations'>
|
||||
@@ -580,6 +594,24 @@ export default function StockDetail() {
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
)}
|
||||
{showTransferAllocations && (
|
||||
<Accordion.Item
|
||||
value='transferAllocations'
|
||||
key='transferAllocations'
|
||||
>
|
||||
<Accordion.Control>
|
||||
<StylishText size='lg'>{t`Transfer Order Allocations`}</StylishText>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<TransferOrderAllocationTable
|
||||
stockId={stockitem.pk}
|
||||
modelField='order'
|
||||
modelTarget={ModelType.transferorder}
|
||||
showOrderInfo
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
)}
|
||||
</Accordion>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Grid, Skeleton, Stack } from '@mantine/core';
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
import { type PanelType, apiUrl } from '@lib/index';
|
||||
import {
|
||||
IconBookmark,
|
||||
IconInfoCircle,
|
||||
IconList,
|
||||
IconListCheck
|
||||
} from '@tabler/icons-react';
|
||||
import AdminButton from '../../components/buttons/AdminButton';
|
||||
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
|
||||
import { PrintingActions } from '../../components/buttons/PrintingActions';
|
||||
import {
|
||||
type DetailsField,
|
||||
DetailsTable
|
||||
} from '../../components/details/Details';
|
||||
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
|
||||
import {
|
||||
BarcodeActionDropdown,
|
||||
CancelItemAction,
|
||||
DuplicateItemAction,
|
||||
EditItemAction,
|
||||
HoldItemAction,
|
||||
OptionsActionDropdown
|
||||
} from '../../components/items/ActionDropdown';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import AttachmentPanel from '../../components/panels/AttachmentPanel';
|
||||
import NotesPanel from '../../components/panels/NotesPanel';
|
||||
import { PanelGroup } from '../../components/panels/PanelGroup';
|
||||
import ParametersPanel from '../../components/panels/ParametersPanel';
|
||||
import { StatusRenderer } from '../../components/render/StatusRenderer';
|
||||
import { useTransferOrderFields } from '../../forms/TransferOrderForms';
|
||||
import {
|
||||
useCreateApiFormModal,
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import useStatusCodes from '../../hooks/UseStatusCodes';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsStates';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import TransferOrderAllocationTable from '../../tables/stock/TransferOrderAllocationTable';
|
||||
import TransferOrderLineItemTable from '../../tables/stock/TransferOrderLineItemTable';
|
||||
|
||||
export default function TransferOrderDetail() {
|
||||
const { id } = useParams();
|
||||
|
||||
const user = useUserState();
|
||||
|
||||
const globalSettings = useGlobalSettingsState();
|
||||
|
||||
const {
|
||||
instance: order,
|
||||
instanceQuery,
|
||||
refreshInstance
|
||||
} = useInstance({
|
||||
endpoint: ApiEndpoints.transfer_order_list,
|
||||
pk: id,
|
||||
params: {}
|
||||
});
|
||||
|
||||
const toStatus = useStatusCodes({ modelType: ModelType.transferorder });
|
||||
|
||||
const lineItemsEditable: boolean = useMemo(() => {
|
||||
const orderOpen: boolean =
|
||||
order.status != toStatus.COMPLETE && order.status != toStatus.CANCELLED;
|
||||
|
||||
return orderOpen;
|
||||
// TODO: does this setting make any sense for Transfer Orders???
|
||||
// if (orderOpen) {
|
||||
// return true;
|
||||
// } else {
|
||||
// return globalSettings.isSet('TRANSFERORDER_EDIT_COMPLETED_ORDERS');
|
||||
// }
|
||||
}, [globalSettings, order.status, toStatus]);
|
||||
|
||||
// for now, only permit editing allocations when line items can be edited
|
||||
const allocationsEditable = lineItemsEditable;
|
||||
|
||||
const orderOpen = useMemo(() => {
|
||||
return (
|
||||
order.status == toStatus.PENDING ||
|
||||
order.status == toStatus.ISSUED ||
|
||||
order.status == toStatus.ON_HOLD
|
||||
);
|
||||
}, [order, toStatus]);
|
||||
|
||||
const detailsPanel = useMemo(() => {
|
||||
if (instanceQuery.isFetching) {
|
||||
return <Skeleton />;
|
||||
}
|
||||
|
||||
const tl: DetailsField[] = [
|
||||
{
|
||||
type: 'text',
|
||||
name: 'reference',
|
||||
label: t`Reference`,
|
||||
copy: true
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
name: 'take_from',
|
||||
icon: 'location',
|
||||
label: t`Source Location`,
|
||||
model: ModelType.stocklocation
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
name: 'destination',
|
||||
icon: 'location',
|
||||
label: t`Destination Location`,
|
||||
model: ModelType.stocklocation
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'description',
|
||||
label: t`Description`,
|
||||
copy: true
|
||||
},
|
||||
{
|
||||
type: 'status',
|
||||
name: 'status',
|
||||
label: t`Status`,
|
||||
model: ModelType.transferorder
|
||||
},
|
||||
{
|
||||
type: 'status',
|
||||
name: 'status_custom_key',
|
||||
label: t`Custom Status`,
|
||||
model: ModelType.transferorder,
|
||||
icon: 'status',
|
||||
hidden:
|
||||
!order.status_custom_key || order.status_custom_key == order.status
|
||||
}
|
||||
];
|
||||
|
||||
const tr: DetailsField[] = [
|
||||
{
|
||||
type: 'boolean',
|
||||
name: 'consume',
|
||||
icon: 'consume',
|
||||
label: t`Consume Stock`
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'line_items',
|
||||
label: t`Line Items`,
|
||||
icon: 'list'
|
||||
},
|
||||
{
|
||||
type: 'progressbar',
|
||||
name: 'completed',
|
||||
icon: 'progress',
|
||||
label: t`Completed Line Items`,
|
||||
total: order.line_items,
|
||||
progress: order.completed_lines
|
||||
}
|
||||
];
|
||||
|
||||
const bl: DetailsField[] = [
|
||||
{
|
||||
type: 'link',
|
||||
external: true,
|
||||
name: 'link',
|
||||
label: t`Link`,
|
||||
copy: true,
|
||||
hidden: !order.link
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'project_code_label',
|
||||
label: t`Project Code`,
|
||||
icon: 'reference',
|
||||
copy: true,
|
||||
hidden: !order.project_code
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'responsible',
|
||||
label: t`Responsible`,
|
||||
badge: 'owner',
|
||||
hidden: !order.responsible
|
||||
}
|
||||
];
|
||||
|
||||
const br: DetailsField[] = [
|
||||
{
|
||||
type: 'date',
|
||||
name: 'creation_date',
|
||||
label: t`Creation Date`,
|
||||
icon: 'calendar',
|
||||
copy: true,
|
||||
hidden: !order.creation_date
|
||||
},
|
||||
{
|
||||
type: 'date',
|
||||
name: 'issue_date',
|
||||
label: t`Issue Date`,
|
||||
icon: 'calendar',
|
||||
copy: true,
|
||||
hidden: !order.issue_date
|
||||
},
|
||||
{
|
||||
type: 'date',
|
||||
name: 'start_date',
|
||||
label: t`Start Date`,
|
||||
icon: 'calendar',
|
||||
copy: true,
|
||||
hidden: !order.start_date
|
||||
},
|
||||
{
|
||||
type: 'date',
|
||||
name: 'target_date',
|
||||
label: t`Target Date`,
|
||||
copy: true,
|
||||
hidden: !order.target_date
|
||||
},
|
||||
{
|
||||
type: 'date',
|
||||
name: 'complete_date',
|
||||
icon: 'calendar_check',
|
||||
label: t`Completion Date`,
|
||||
copy: true,
|
||||
hidden: !order.complete_date
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<ItemDetailsGrid>
|
||||
<Grid grow>
|
||||
{/* TODO: what image do we show for a Transfer Order? */}
|
||||
{/* <DetailsImage
|
||||
appRole={UserRoles.transfer_order}
|
||||
apiPath={ApiEndpoints.transfer_order_list}
|
||||
src="/static/img/blank_image.png"
|
||||
pk={order.pk}
|
||||
/> */}
|
||||
<Grid.Col span={{ base: 12, sm: 8 }}>
|
||||
<DetailsTable fields={tl} item={order} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<DetailsTable fields={tr} item={order} />
|
||||
<DetailsTable fields={bl} item={order} />
|
||||
<DetailsTable fields={br} item={order} />
|
||||
</ItemDetailsGrid>
|
||||
);
|
||||
}, [order, instanceQuery]);
|
||||
|
||||
const orderPanels: PanelType[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
name: 'detail',
|
||||
label: t`Order Details`,
|
||||
icon: <IconInfoCircle />,
|
||||
content: detailsPanel
|
||||
},
|
||||
{
|
||||
name: 'line-items',
|
||||
label: t`Line Items`,
|
||||
icon: <IconList />,
|
||||
content: (
|
||||
<TransferOrderLineItemTable
|
||||
orderId={order.pk}
|
||||
sourceLocationId={order.take_from}
|
||||
orderDetailRefresh={refreshInstance}
|
||||
editable={lineItemsEditable}
|
||||
/>
|
||||
// TODO: add back the accordion if we need extra lines
|
||||
// <Accordion
|
||||
// multiple={true}
|
||||
// defaultValue={[
|
||||
// 'line-items',
|
||||
// // 'extra-items'
|
||||
// ]}
|
||||
// >
|
||||
// <Accordion.Item value='line-items' key='lineitems'>
|
||||
// <Accordion.Control>
|
||||
// <StylishText size='lg'>{t`Line Items`}</StylishText>
|
||||
// </Accordion.Control>
|
||||
// <Accordion.Panel>
|
||||
// <TransferOrderLineItemTable
|
||||
// orderId={order.pk}
|
||||
// orderDetailRefresh={refreshInstance}
|
||||
// editable={lineItemsEditable}
|
||||
// />
|
||||
// </Accordion.Panel>
|
||||
// </Accordion.Item>
|
||||
// {/* <Accordion.Item value='extra-items' key='extraitems'>
|
||||
// <Accordion.Control>
|
||||
// <StylishText size='lg'>{t`Extra Line Items`}</StylishText>
|
||||
// </Accordion.Control>
|
||||
// <Accordion.Panel>
|
||||
// <ExtraLineItemTable
|
||||
// endpoint={ApiEndpoints.sales_order_extra_line_list}
|
||||
// orderId={order.pk}
|
||||
// editable={lineItemsEditable}
|
||||
// orderDetailRefresh={refreshInstance}
|
||||
// currency={orderCurrency}
|
||||
// role={UserRoles.sales_order}
|
||||
// />
|
||||
// </Accordion.Panel>
|
||||
// </Accordion.Item> */}
|
||||
// </Accordion>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'allocations',
|
||||
label:
|
||||
order.status != toStatus.COMPLETE
|
||||
? t`Allocated Stock`
|
||||
: t`Transferred Stock`,
|
||||
icon:
|
||||
order.status != toStatus.COMPLETE ? (
|
||||
<IconBookmark />
|
||||
) : (
|
||||
<IconListCheck />
|
||||
),
|
||||
content: (
|
||||
<TransferOrderAllocationTable
|
||||
orderId={order.pk}
|
||||
showPartInfo
|
||||
allowEdit={allocationsEditable}
|
||||
modelField='item'
|
||||
modelTarget={ModelType.stockitem}
|
||||
/>
|
||||
)
|
||||
},
|
||||
ParametersPanel({
|
||||
model_type: ModelType.transferorder,
|
||||
model_id: order.pk
|
||||
}),
|
||||
AttachmentPanel({
|
||||
model_type: ModelType.transferorder,
|
||||
model_id: order.pk
|
||||
}),
|
||||
NotesPanel({
|
||||
model_type: ModelType.transferorder,
|
||||
model_id: order.pk
|
||||
})
|
||||
];
|
||||
}, [order, id, user]);
|
||||
|
||||
const orderBadges: ReactNode[] = useMemo(() => {
|
||||
return instanceQuery.isLoading
|
||||
? []
|
||||
: [
|
||||
<StatusRenderer
|
||||
status={order.status_custom_key}
|
||||
type={ModelType.transferorder}
|
||||
options={{ size: 'lg' }}
|
||||
/>
|
||||
];
|
||||
}, [order, instanceQuery]);
|
||||
|
||||
const transferOrderFields = useTransferOrderFields({});
|
||||
|
||||
const duplicateTransferOrderFields = useTransferOrderFields({
|
||||
duplicateOrderId: order.pk
|
||||
});
|
||||
|
||||
const editTransferOrder = useEditApiFormModal({
|
||||
url: ApiEndpoints.transfer_order_list,
|
||||
pk: order.pk,
|
||||
title: t`Edit Transfer Order`,
|
||||
fields: transferOrderFields,
|
||||
onFormSuccess: () => {
|
||||
refreshInstance();
|
||||
}
|
||||
});
|
||||
|
||||
const duplicateTransferOrderInitialData = useMemo(() => {
|
||||
const data = { ...order };
|
||||
// if we set the reference to null/undefined, it will be left blank in the form
|
||||
// if we omit the reference altogether, it will be auto-generated via reference pattern
|
||||
// from the OPTIONS response
|
||||
delete data.reference;
|
||||
return data;
|
||||
}, [order]);
|
||||
|
||||
const duplicateTransferOrder = useCreateApiFormModal({
|
||||
url: ApiEndpoints.transfer_order_list,
|
||||
title: t`Add Transfer Order`,
|
||||
fields: duplicateTransferOrderFields,
|
||||
initialData: duplicateTransferOrderInitialData,
|
||||
modelType: ModelType.transferorder,
|
||||
follow: true
|
||||
});
|
||||
|
||||
const issueOrder = useCreateApiFormModal({
|
||||
url: apiUrl(ApiEndpoints.transfer_order_issue, order.pk),
|
||||
title: t`Issue Transfer Order`,
|
||||
onFormSuccess: refreshInstance,
|
||||
preFormWarning: t`Issue this order`,
|
||||
successMessage: t`Order issued`
|
||||
});
|
||||
|
||||
const cancelOrder = useCreateApiFormModal({
|
||||
url: apiUrl(ApiEndpoints.transfer_order_cancel, order.pk),
|
||||
title: t`Cancel Transfer Order`,
|
||||
onFormSuccess: refreshInstance,
|
||||
preFormWarning: t`Cancel this order`,
|
||||
successMessage: t`Order cancelled`
|
||||
});
|
||||
|
||||
const holdOrder = useCreateApiFormModal({
|
||||
url: apiUrl(ApiEndpoints.transfer_order_hold, order.pk),
|
||||
title: t`Hold Transfer Order`,
|
||||
onFormSuccess: refreshInstance,
|
||||
preFormWarning: t`Place this order on hold`,
|
||||
successMessage: t`Order placed on hold`
|
||||
});
|
||||
|
||||
const completeOrder = useCreateApiFormModal({
|
||||
url: apiUrl(ApiEndpoints.transfer_order_complete, order.pk),
|
||||
title: t`Complete Transfer Order`,
|
||||
onFormSuccess: refreshInstance,
|
||||
preFormWarning: t`Mark this order as complete`,
|
||||
successMessage: t`Order completed`,
|
||||
fields: {
|
||||
accept_incomplete_allocation: {}
|
||||
}
|
||||
});
|
||||
|
||||
const orderActions = useMemo(() => {
|
||||
const canEdit: boolean = user.hasChangeRole(UserRoles.transfer_order);
|
||||
|
||||
const canIssue: boolean =
|
||||
canEdit &&
|
||||
(order.status == toStatus.PENDING || order.status == toStatus.ON_HOLD);
|
||||
|
||||
const canHold: boolean =
|
||||
canEdit &&
|
||||
(order.status == toStatus.PENDING || order.status == toStatus.ISSUED);
|
||||
|
||||
const canCancel: boolean =
|
||||
canEdit &&
|
||||
(order.status == toStatus.PENDING || order.status == toStatus.ON_HOLD);
|
||||
|
||||
const canComplete: boolean = canEdit && order.status == toStatus.ISSUED;
|
||||
|
||||
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.transferorder} id={order.pk} />,
|
||||
<BarcodeActionDropdown
|
||||
model={ModelType.transferorder}
|
||||
pk={order.pk}
|
||||
hash={order?.barcode_hash}
|
||||
/>,
|
||||
<PrintingActions
|
||||
modelType={ModelType.transferorder}
|
||||
items={[order.pk]}
|
||||
enableReports
|
||||
enableLabels
|
||||
/>,
|
||||
<OptionsActionDropdown
|
||||
tooltip={t`Order Actions`}
|
||||
actions={[
|
||||
EditItemAction({
|
||||
hidden: !user.hasChangeRole(UserRoles.transfer_order),
|
||||
tooltip: t`Edit order`,
|
||||
onClick: () => {
|
||||
editTransferOrder.open();
|
||||
}
|
||||
}),
|
||||
DuplicateItemAction({
|
||||
tooltip: t`Duplicate order`,
|
||||
hidden: !user.hasChangeRole(UserRoles.transfer_order),
|
||||
onClick: () => duplicateTransferOrder.open()
|
||||
}),
|
||||
HoldItemAction({
|
||||
tooltip: t`Hold order`,
|
||||
hidden: !canHold,
|
||||
onClick: () => holdOrder.open()
|
||||
}),
|
||||
CancelItemAction({
|
||||
tooltip: t`Cancel order`,
|
||||
hidden: !canCancel,
|
||||
onClick: () => cancelOrder.open()
|
||||
})
|
||||
]}
|
||||
/>
|
||||
];
|
||||
}, [user, order, orderOpen, toStatus]);
|
||||
|
||||
const subtitle: string = useMemo(() => {
|
||||
const t = order.take_from_detail?.pathstring || '';
|
||||
const d = order.destination_detail?.pathstring || '';
|
||||
return `${t} → ${d}`;
|
||||
}, [order]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{editTransferOrder.modal}
|
||||
{issueOrder.modal}
|
||||
{cancelOrder.modal}
|
||||
{holdOrder.modal}
|
||||
{completeOrder.modal}
|
||||
{duplicateTransferOrder.modal}
|
||||
<InstanceDetail
|
||||
query={instanceQuery}
|
||||
requiredRole={UserRoles.transfer_order}
|
||||
>
|
||||
<Stack gap='xs'>
|
||||
<PageDetail
|
||||
title={`${t`Transfer Order`}: ${order.reference}`}
|
||||
subtitle={subtitle}
|
||||
// What should be the Transfer Order image?
|
||||
// imageUrl={order.customer_detail?.image}
|
||||
badges={orderBadges}
|
||||
actions={orderActions}
|
||||
breadcrumbs={[{ name: t`Stock`, url: '/stock/' }]}
|
||||
lastCrumb={[
|
||||
{
|
||||
name: order.reference,
|
||||
url: `/stock/transfer-order/${order.pk}`
|
||||
}
|
||||
]}
|
||||
editAction={editTransferOrder.open}
|
||||
editEnabled={user.hasChangePermission(ModelType.transferorder)}
|
||||
/>
|
||||
<PanelGroup
|
||||
pageKey='transferorder'
|
||||
panels={orderPanels}
|
||||
model={ModelType.transferorder}
|
||||
reloadInstance={instanceQuery.refetch}
|
||||
instance={order}
|
||||
id={order.pk}
|
||||
/>
|
||||
</Stack>
|
||||
</InstanceDetail>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -88,6 +88,10 @@ export const ReturnOrderDetail = Loadable(
|
||||
lazy(() => import('./pages/sales/ReturnOrderDetail'))
|
||||
);
|
||||
|
||||
export const TransferOrderDetail = Loadable(
|
||||
lazy(() => import('./pages/stock/TransferOrderDetail'))
|
||||
);
|
||||
|
||||
export const Scan = Loadable(lazy(() => import('./pages/Index/Scan')));
|
||||
|
||||
export const ErrorPage = Loadable(lazy(() => import('./pages/ErrorPage')));
|
||||
@@ -169,6 +173,7 @@ export const routes = (
|
||||
<Route index element={<Navigate to='location/index/' />} />
|
||||
<Route path='location/:id?/*' element={<LocationDetail />} />
|
||||
<Route path='item/:id/*' element={<StockDetail />} />
|
||||
<Route path='transfer-order/:id/*' element={<TransferOrderDetail />} />
|
||||
</Route>
|
||||
<Route path='manufacturing/'>
|
||||
<Route index element={<Navigate to='index/' />} />
|
||||
|
||||
@@ -15,7 +15,8 @@ import { RenderCompany } from '../../components/render/Company';
|
||||
import {
|
||||
RenderPurchaseOrder,
|
||||
RenderReturnOrder,
|
||||
RenderSalesOrder
|
||||
RenderSalesOrder,
|
||||
RenderTransferOrder
|
||||
} from '../../components/render/Order';
|
||||
import { RenderPart } from '../../components/render/Part';
|
||||
import { StatusRenderer } from '../../components/render/StatusRenderer';
|
||||
@@ -181,6 +182,17 @@ export function StockTrackingTable({
|
||||
navigate: navigate
|
||||
})
|
||||
},
|
||||
{
|
||||
label: t`Transfer Order`,
|
||||
key: 'transferorder',
|
||||
details:
|
||||
deltas.transferorder_detail &&
|
||||
RenderTransferOrder({
|
||||
instance: deltas.transferorder_detail,
|
||||
link: true,
|
||||
navigate: navigate
|
||||
})
|
||||
},
|
||||
{
|
||||
label: t`Customer`,
|
||||
key: 'customer',
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { type RowAction, RowEditAction } from '@lib/components/RowActions';
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import useTable from '@lib/hooks/UseTable';
|
||||
import type { TableFilter } from '@lib/types/Filters';
|
||||
import type { StockOperationProps } from '@lib/types/Forms';
|
||||
import type { TableColumn } from '@lib/types/Tables';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Alert } from '@mantine/core';
|
||||
import { IconCircleX } from '@tabler/icons-react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useTransferOrderAllocationFields } from '../../forms/TransferOrderForms';
|
||||
import {
|
||||
useDeleteApiFormModal,
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import {
|
||||
DescriptionColumn,
|
||||
LocationColumn,
|
||||
PartColumn,
|
||||
ReferenceColumn,
|
||||
StatusColumn
|
||||
} from '../ColumnRenderers';
|
||||
import { IncludeVariantsFilter, StockLocationFilter } from '../Filter';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
|
||||
export default function TransferOrderAllocationTable({
|
||||
partId,
|
||||
stockId,
|
||||
orderId,
|
||||
lineItemId,
|
||||
showPartInfo,
|
||||
showOrderInfo,
|
||||
allowEdit,
|
||||
isSubTable,
|
||||
modelTarget,
|
||||
modelField
|
||||
}: Readonly<{
|
||||
partId?: number;
|
||||
stockId?: number;
|
||||
orderId?: number;
|
||||
lineItemId?: number;
|
||||
showPartInfo?: boolean;
|
||||
showOrderInfo?: boolean;
|
||||
allowEdit?: boolean;
|
||||
isSubTable?: boolean;
|
||||
modelTarget?: ModelType;
|
||||
modelField?: string;
|
||||
}>) {
|
||||
const user = useUserState();
|
||||
|
||||
const tableId = useMemo(() => {
|
||||
let id = 'transferorderallocations';
|
||||
|
||||
if (!!partId) {
|
||||
id += '-part';
|
||||
}
|
||||
|
||||
if (isSubTable) {
|
||||
id += '-sub';
|
||||
}
|
||||
|
||||
return id;
|
||||
}, [partId, isSubTable]);
|
||||
|
||||
const table = useTable(tableId);
|
||||
|
||||
const [selectedAllocation, setSelectedAllocation] = useState<number>(0);
|
||||
|
||||
const tableFilters: TableFilter[] = useMemo(() => {
|
||||
const filters: TableFilter[] = [
|
||||
{
|
||||
name: 'outstanding',
|
||||
label: t`Outstanding`,
|
||||
description: t`Show outstanding allocations`
|
||||
},
|
||||
StockLocationFilter()
|
||||
];
|
||||
|
||||
if (!!partId) {
|
||||
filters.push(IncludeVariantsFilter());
|
||||
}
|
||||
|
||||
return filters;
|
||||
}, [partId]);
|
||||
|
||||
const tableColumns: TableColumn[] = useMemo(() => {
|
||||
return [
|
||||
ReferenceColumn({
|
||||
accessor: 'order_detail.reference',
|
||||
title: t`Transfer Order`,
|
||||
switchable: false,
|
||||
sortable: true,
|
||||
hidden: showOrderInfo != true
|
||||
}),
|
||||
DescriptionColumn({
|
||||
accessor: 'order_detail.description',
|
||||
hidden: showOrderInfo != true
|
||||
}),
|
||||
StatusColumn({
|
||||
accessor: 'order_detail.status',
|
||||
model: ModelType.transferorder,
|
||||
title: t`Order Status`,
|
||||
hidden: showOrderInfo != true
|
||||
}),
|
||||
PartColumn({
|
||||
hidden: showPartInfo != true,
|
||||
part: 'part_detail'
|
||||
}),
|
||||
DescriptionColumn({
|
||||
accessor: 'part_detail.description',
|
||||
hidden: showPartInfo != true
|
||||
}),
|
||||
{
|
||||
accessor: 'part_detail.IPN',
|
||||
title: t`IPN`,
|
||||
hidden: showPartInfo != true,
|
||||
sortable: true,
|
||||
ordering: 'IPN'
|
||||
},
|
||||
{
|
||||
accessor: 'serial',
|
||||
title: t`Serial Number`,
|
||||
sortable: true,
|
||||
switchable: true,
|
||||
render: (record: any) => record?.item_detail?.serial
|
||||
},
|
||||
{
|
||||
accessor: 'batch',
|
||||
title: t`Batch Code`,
|
||||
sortable: true,
|
||||
switchable: true,
|
||||
render: (record: any) => record?.item_detail?.batch
|
||||
},
|
||||
{
|
||||
accessor: 'available',
|
||||
title: t`Available Quantity`,
|
||||
sortable: false,
|
||||
hidden: isSubTable,
|
||||
render: (record: any) => record?.item_detail?.quantity
|
||||
},
|
||||
{
|
||||
accessor: 'quantity',
|
||||
title: t`Allocated Quantity`,
|
||||
sortable: true
|
||||
},
|
||||
LocationColumn({
|
||||
accessor: 'location_detail',
|
||||
switchable: true,
|
||||
sortable: true
|
||||
})
|
||||
];
|
||||
}, [showOrderInfo, showPartInfo, isSubTable]);
|
||||
|
||||
const editAllocationFields = useTransferOrderAllocationFields({
|
||||
orderId: orderId
|
||||
});
|
||||
|
||||
const editAllocation = useEditApiFormModal({
|
||||
url: ApiEndpoints.transfer_order_allocation_list,
|
||||
pk: selectedAllocation,
|
||||
fields: editAllocationFields,
|
||||
title: t`Edit Allocation`,
|
||||
onFormSuccess: () => table.refreshTable()
|
||||
});
|
||||
|
||||
const deleteAllocation = useDeleteApiFormModal({
|
||||
url: ApiEndpoints.transfer_order_allocation_list,
|
||||
pk: selectedAllocation,
|
||||
title: t`Remove Allocated Stock`,
|
||||
preFormContent: (
|
||||
<Alert color='red' title={t`Confirm Removal`}>
|
||||
{t`Are you sure you want to remove this allocated stock from the order?`}
|
||||
</Alert>
|
||||
),
|
||||
submitText: t`Remove`,
|
||||
onFormSuccess: () => table.refreshTable()
|
||||
});
|
||||
|
||||
const rowActions = useCallback(
|
||||
(record: any): RowAction[] => {
|
||||
return [
|
||||
RowEditAction({
|
||||
tooltip: t`Edit Allocation`,
|
||||
hidden: !allowEdit || !user.hasChangeRole(UserRoles.transfer_order),
|
||||
onClick: () => {
|
||||
setSelectedAllocation(record.pk);
|
||||
editAllocation.open();
|
||||
}
|
||||
}),
|
||||
{
|
||||
title: t`Remove`,
|
||||
tooltip: t`Remove allocated stock`,
|
||||
icon: <IconCircleX />,
|
||||
color: 'red',
|
||||
hidden: !allowEdit || !user.hasDeleteRole(UserRoles.transfer_order),
|
||||
onClick: () => {
|
||||
setSelectedAllocation(record.pk);
|
||||
deleteAllocation.open();
|
||||
}
|
||||
}
|
||||
];
|
||||
},
|
||||
[allowEdit, user]
|
||||
);
|
||||
|
||||
const stockOperationProps: StockOperationProps = useMemo(() => {
|
||||
// Extract stock items from the selected records
|
||||
// Note that the table is actually a list of TransferOrderAllocation instances,
|
||||
// so we need to reconstruct the stock item details
|
||||
const stockItems: any[] = table.selectedRecords
|
||||
.filter((item: any) => !!item.item_detail)
|
||||
.map((item: any) => {
|
||||
return {
|
||||
...item.item_detail,
|
||||
part_detail: item.part_detail,
|
||||
location_detail: item.location_detail
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
items: stockItems,
|
||||
model: ModelType.stockitem,
|
||||
refresh: table.refreshTable
|
||||
};
|
||||
}, [table.selectedRecords, table.refreshTable]);
|
||||
|
||||
const stockAdjustActions = useStockAdjustActions({
|
||||
formProps: stockOperationProps,
|
||||
merge: false,
|
||||
assign: false,
|
||||
delete: false,
|
||||
add: false,
|
||||
count: false,
|
||||
remove: false
|
||||
});
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
return [stockAdjustActions.dropdown];
|
||||
}, [allowEdit, orderId, user, stockAdjustActions.dropdown]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{editAllocation.modal}
|
||||
{deleteAllocation.modal}
|
||||
{!isSubTable && stockAdjustActions.modals.map((modal) => modal.modal)}
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.transfer_order_allocation_list)}
|
||||
tableState={table}
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
params: {
|
||||
part_detail: showPartInfo ?? false,
|
||||
order_detail: showOrderInfo ?? false,
|
||||
item_detail: true,
|
||||
location_detail: true,
|
||||
line: lineItemId,
|
||||
part: partId,
|
||||
order: orderId,
|
||||
item: stockId
|
||||
},
|
||||
enableSearch: !isSubTable,
|
||||
enableRefresh: !isSubTable,
|
||||
enableColumnSwitching: !isSubTable,
|
||||
enableFilters: !isSubTable,
|
||||
enableDownload: !isSubTable,
|
||||
enableSelection: !isSubTable,
|
||||
minHeight: isSubTable ? 100 : undefined,
|
||||
rowActions: rowActions,
|
||||
tableActions: isSubTable ? undefined : tableActions,
|
||||
tableFilters: tableFilters,
|
||||
modelField: modelField ?? 'order',
|
||||
enableReports: !isSubTable,
|
||||
enableLabels: !isSubTable,
|
||||
printingAccessor: 'item',
|
||||
modelType: modelTarget ?? ModelType.transferorder
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import useTable from '@lib/hooks/UseTable';
|
||||
import {
|
||||
ActionButton,
|
||||
AddItemButton,
|
||||
ModelType,
|
||||
ProgressBar,
|
||||
RowDeleteAction,
|
||||
RowDuplicateAction,
|
||||
RowEditAction,
|
||||
RowViewAction,
|
||||
UserRoles,
|
||||
formatDecimal
|
||||
} from '@lib/index';
|
||||
import type { TableFilter } from '@lib/types/Filters';
|
||||
import type { RowAction, TableColumn } from '@lib/types/Tables';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Group, Paper, Text } from '@mantine/core';
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconHash,
|
||||
IconShoppingCart,
|
||||
IconSquareArrowRight,
|
||||
IconTools
|
||||
} from '@tabler/icons-react';
|
||||
import type { DataTableRowExpansionProps } from 'mantine-datatable';
|
||||
import { type ReactNode, useCallback, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { RenderPart } from '../../components/render/Part';
|
||||
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
|
||||
import { useBuildOrderFields } from '../../forms/BuildForms';
|
||||
import {
|
||||
useAllocateToTransferOrderForm,
|
||||
useTransferOrderAllocateSerialsFields,
|
||||
useTransferOrderLineItemFields
|
||||
} from '../../forms/TransferOrderForms';
|
||||
import {
|
||||
useCreateApiFormModal,
|
||||
useDeleteApiFormModal,
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import {
|
||||
DateColumn,
|
||||
DecimalColumn,
|
||||
DescriptionColumn,
|
||||
LinkColumn,
|
||||
ProjectCodeColumn,
|
||||
RenderPartColumn
|
||||
} from '../ColumnRenderers';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
import RowExpansionIcon from '../RowExpansionIcon';
|
||||
import { TableHoverCard } from '../TableHoverCard';
|
||||
import TransferOrderAllocationTable from './TransferOrderAllocationTable';
|
||||
|
||||
export default function TransferOrderLineItemTable({
|
||||
orderId,
|
||||
sourceLocationId,
|
||||
orderDetailRefresh,
|
||||
editable
|
||||
}: Readonly<{
|
||||
orderId: number;
|
||||
sourceLocationId?: number;
|
||||
orderDetailRefresh: () => void;
|
||||
editable: boolean;
|
||||
}>) {
|
||||
const navigate = useNavigate();
|
||||
const user = useUserState();
|
||||
const table = useTable('transfer-order-line-item');
|
||||
|
||||
const tableColumns: TableColumn[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
accessor: 'part',
|
||||
sortable: true,
|
||||
switchable: false,
|
||||
minWidth: 175,
|
||||
render: (record: any) => {
|
||||
return (
|
||||
<Group wrap='nowrap'>
|
||||
{record.part_detail?.virtual || (
|
||||
<RowExpansionIcon
|
||||
enabled={record.allocated}
|
||||
expanded={table.isRowExpanded(record.pk)}
|
||||
/>
|
||||
)}
|
||||
<RenderPartColumn part={record.part_detail} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'part_detail.IPN',
|
||||
title: t`IPN`,
|
||||
switchable: true
|
||||
},
|
||||
DescriptionColumn({
|
||||
accessor: 'part_detail.description'
|
||||
}),
|
||||
{
|
||||
accessor: 'reference',
|
||||
sortable: false,
|
||||
switchable: true
|
||||
},
|
||||
ProjectCodeColumn({}),
|
||||
DecimalColumn({
|
||||
accessor: 'quantity',
|
||||
sortable: true
|
||||
}),
|
||||
DateColumn({
|
||||
accessor: 'target_date',
|
||||
sortable: true,
|
||||
title: t`Target Date`
|
||||
}),
|
||||
{
|
||||
accessor: 'stock',
|
||||
title: t`Available Stock`,
|
||||
render: (record: any) => {
|
||||
if (record.part_detail?.virtual) {
|
||||
return <Text size='sm' fs='italic'>{t`Virtual part`}</Text>;
|
||||
}
|
||||
|
||||
const part_stock = record?.available_stock ?? 0;
|
||||
const variant_stock = record?.available_variant_stock ?? 0;
|
||||
const available = part_stock + variant_stock;
|
||||
|
||||
const required = Math.max(
|
||||
record.quantity - record.allocated - record.shipped,
|
||||
0
|
||||
);
|
||||
|
||||
let color: string | undefined;
|
||||
let text = `${formatDecimal(available)}`;
|
||||
|
||||
const extra: ReactNode[] = [];
|
||||
|
||||
if (available <= 0) {
|
||||
color = 'red';
|
||||
text = t`No stock available`;
|
||||
} else if (available < required) {
|
||||
color = 'orange';
|
||||
}
|
||||
|
||||
if (variant_stock > 0) {
|
||||
extra.push(<Text size='sm'>{t`Includes variant stock`}</Text>);
|
||||
}
|
||||
|
||||
if (record.building > 0) {
|
||||
extra.push(
|
||||
<Text size='sm'>
|
||||
{t`In production`}: {formatDecimal(record.building)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (record.on_order > 0) {
|
||||
extra.push(
|
||||
<Text size='sm'>
|
||||
{t`On order`}: {formatDecimal(record.on_order)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableHoverCard
|
||||
value={<Text c={color}>{text}</Text>}
|
||||
extra={extra}
|
||||
title={t`Stock Information`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
accessor: 'allocated',
|
||||
sortable: true,
|
||||
render: (record: any) => {
|
||||
if (record.part_detail?.virtual) {
|
||||
return <Text size='sm' fs='italic'>{t`Virtual part`}</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ProgressBar
|
||||
progressLabel={true}
|
||||
value={record.allocated}
|
||||
maximum={record.quantity}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'transferred',
|
||||
title: t`Transferred`,
|
||||
sortable: true,
|
||||
render: (record: any) => {
|
||||
if (record.part_detail?.virtual) {
|
||||
return <Text size='sm' fs='italic'>{t`Virtual part`}</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ProgressBar
|
||||
progressLabel={true}
|
||||
value={record.transferred}
|
||||
maximum={record.quantity}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'notes'
|
||||
},
|
||||
LinkColumn({
|
||||
accessor: 'link'
|
||||
})
|
||||
];
|
||||
}, [table.isRowExpanded]);
|
||||
|
||||
const [initialData, setInitialData] = useState({});
|
||||
const [selectedItems, setSelectedItems] = useState<any[]>([]);
|
||||
const [selectedLineId, setSelectedLineId] = useState<number>(0);
|
||||
const [selectedPart, setSelectedPart] = useState<any>(null);
|
||||
const [partsToOrder, setPartsToOrder] = useState<any[]>([]);
|
||||
|
||||
const allocateStock = useAllocateToTransferOrderForm({
|
||||
orderId: orderId,
|
||||
lineItems: selectedItems.filter(
|
||||
(item) => item.part_detail?.virtual !== true
|
||||
),
|
||||
sourceLocationId: sourceLocationId,
|
||||
onFormSuccess: () => {
|
||||
table.refreshTable();
|
||||
table.clearSelectedRecords();
|
||||
}
|
||||
});
|
||||
|
||||
const orderPartsWizard = OrderPartsWizard({
|
||||
parts: partsToOrder
|
||||
});
|
||||
|
||||
const buildOrderFields = useBuildOrderFields({
|
||||
create: true,
|
||||
modalId: 'build-order-create-from-transfer-order'
|
||||
});
|
||||
|
||||
const newBuildOrder = useCreateApiFormModal({
|
||||
url: ApiEndpoints.build_order_list,
|
||||
title: t`Create Build Order`,
|
||||
modalId: 'build-order-create-from-transfer-order',
|
||||
fields: buildOrderFields,
|
||||
initialData: initialData,
|
||||
follow: true,
|
||||
modelType: ModelType.build
|
||||
});
|
||||
|
||||
const createLineFields = useTransferOrderLineItemFields({
|
||||
orderId: orderId,
|
||||
create: true
|
||||
});
|
||||
|
||||
const newLine = useCreateApiFormModal({
|
||||
url: ApiEndpoints.transfer_order_line_list,
|
||||
title: t`Add Line Item`,
|
||||
fields: createLineFields,
|
||||
initialData: {
|
||||
...initialData
|
||||
},
|
||||
onFormSuccess: orderDetailRefresh,
|
||||
table: table
|
||||
});
|
||||
|
||||
const editLineFields = useTransferOrderLineItemFields({
|
||||
orderId: orderId,
|
||||
create: false
|
||||
});
|
||||
|
||||
const editLine = useEditApiFormModal({
|
||||
url: ApiEndpoints.transfer_order_line_list,
|
||||
pk: selectedLineId,
|
||||
title: t`Edit Line Item`,
|
||||
fields: editLineFields,
|
||||
onFormSuccess: orderDetailRefresh,
|
||||
table: table
|
||||
});
|
||||
|
||||
const deleteLine = useDeleteApiFormModal({
|
||||
url: ApiEndpoints.transfer_order_line_list,
|
||||
pk: selectedLineId,
|
||||
title: t`Delete Line Item`,
|
||||
onFormSuccess: orderDetailRefresh,
|
||||
table: table
|
||||
});
|
||||
|
||||
const allocateSerialFields = useTransferOrderAllocateSerialsFields({
|
||||
itemId: selectedLineId,
|
||||
orderId: orderId
|
||||
});
|
||||
|
||||
const allocateBySerials = useCreateApiFormModal({
|
||||
url: ApiEndpoints.transfer_order_allocate_serials,
|
||||
pk: orderId,
|
||||
title: t`Allocate Serial Numbers`,
|
||||
preFormContent: selectedPart ? (
|
||||
<Paper withBorder p='sm'>
|
||||
<RenderPart instance={selectedPart} />
|
||||
</Paper>
|
||||
) : undefined,
|
||||
initialData: initialData,
|
||||
fields: allocateSerialFields,
|
||||
table: table
|
||||
});
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
return [
|
||||
<AddItemButton
|
||||
key='add-transfer-order-line-item'
|
||||
tooltip={t`Add Line Item`}
|
||||
onClick={() => {
|
||||
setInitialData({
|
||||
order: orderId
|
||||
});
|
||||
newLine.open();
|
||||
}}
|
||||
hidden={!editable || !user.hasAddRole(UserRoles.transfer_order)}
|
||||
/>,
|
||||
<ActionButton
|
||||
key='order-parts'
|
||||
hidden={!user.hasAddRole(UserRoles.purchase_order)}
|
||||
disabled={!table.hasSelectedRecords}
|
||||
tooltip={t`Order Parts`}
|
||||
icon={<IconShoppingCart />}
|
||||
color='blue'
|
||||
onClick={() => {
|
||||
setPartsToOrder(table.selectedRecords.map((r) => r.part_detail));
|
||||
orderPartsWizard.openWizard();
|
||||
}}
|
||||
/>,
|
||||
<ActionButton
|
||||
key='allocate-stock'
|
||||
tooltip={t`Allocate Stock`}
|
||||
icon={<IconArrowRight />}
|
||||
disabled={!table.hasSelectedRecords}
|
||||
color='green'
|
||||
onClick={() => {
|
||||
setSelectedItems(
|
||||
table.selectedRecords.filter((r: any) => r.allocated < r.quantity)
|
||||
);
|
||||
allocateStock.open();
|
||||
}}
|
||||
/>
|
||||
];
|
||||
}, [user, orderId, table.hasSelectedRecords, table.selectedRecords]);
|
||||
|
||||
const rowActions = useCallback(
|
||||
(record: any): RowAction[] => {
|
||||
const allocated = (record?.allocated ?? 0) > (record?.quantity ?? 0);
|
||||
const virtual = record?.part_detail?.virtual ?? false;
|
||||
|
||||
return [
|
||||
{
|
||||
hidden:
|
||||
allocated ||
|
||||
virtual ||
|
||||
!editable ||
|
||||
!user.hasChangeRole(UserRoles.transfer_order),
|
||||
title: t`Allocate Stock`,
|
||||
icon: <IconSquareArrowRight />,
|
||||
color: 'green',
|
||||
onClick: () => {
|
||||
setSelectedItems([record]);
|
||||
allocateStock.open();
|
||||
}
|
||||
},
|
||||
{
|
||||
hidden:
|
||||
!record?.part_detail?.trackable ||
|
||||
allocated ||
|
||||
virtual ||
|
||||
!editable ||
|
||||
!user.hasChangeRole(UserRoles.transfer_order),
|
||||
title: t`Allocate serials`,
|
||||
icon: <IconHash />,
|
||||
color: 'green',
|
||||
onClick: () => {
|
||||
setSelectedLineId(record.pk);
|
||||
setSelectedPart(record?.part_detail ?? null);
|
||||
setInitialData({
|
||||
quantity: record.quantity - record.allocated
|
||||
});
|
||||
allocateBySerials.open();
|
||||
}
|
||||
},
|
||||
{
|
||||
hidden:
|
||||
allocated ||
|
||||
virtual ||
|
||||
!user.hasAddRole(UserRoles.build) ||
|
||||
!record?.part_detail?.assembly,
|
||||
title: t`Build stock`,
|
||||
icon: <IconTools />,
|
||||
color: 'blue',
|
||||
onClick: () => {
|
||||
setInitialData({
|
||||
part: record.part,
|
||||
quantity: (record?.quantity ?? 1) - (record?.allocated ?? 0),
|
||||
transfer_order: orderId
|
||||
});
|
||||
newBuildOrder.open();
|
||||
}
|
||||
},
|
||||
{
|
||||
hidden:
|
||||
allocated ||
|
||||
virtual ||
|
||||
!user.hasAddRole(UserRoles.purchase_order) ||
|
||||
!record?.part_detail?.purchaseable,
|
||||
title: t`Order stock`,
|
||||
icon: <IconShoppingCart />,
|
||||
color: 'blue',
|
||||
onClick: () => {
|
||||
setPartsToOrder([record.part_detail]);
|
||||
orderPartsWizard.openWizard();
|
||||
}
|
||||
},
|
||||
RowEditAction({
|
||||
hidden: !editable || !user.hasChangeRole(UserRoles.transfer_order),
|
||||
onClick: () => {
|
||||
setSelectedLineId(record.pk);
|
||||
editLine.open();
|
||||
}
|
||||
}),
|
||||
RowDuplicateAction({
|
||||
hidden: !editable || !user.hasAddRole(UserRoles.transfer_order),
|
||||
onClick: () => {
|
||||
setInitialData(record);
|
||||
newLine.open();
|
||||
}
|
||||
}),
|
||||
RowDeleteAction({
|
||||
hidden: !editable || !user.hasDeleteRole(UserRoles.transfer_order),
|
||||
onClick: () => {
|
||||
setSelectedLineId(record.pk);
|
||||
deleteLine.open();
|
||||
}
|
||||
}),
|
||||
RowViewAction({
|
||||
title: t`View Part`,
|
||||
modelType: ModelType.part,
|
||||
modelId: record.part,
|
||||
navigate: navigate,
|
||||
hidden: !user.hasViewRole(UserRoles.part)
|
||||
})
|
||||
];
|
||||
},
|
||||
[navigate, user, editable]
|
||||
);
|
||||
|
||||
// Control row expansion
|
||||
const rowExpansion: DataTableRowExpansionProps<any> = useMemo(() => {
|
||||
return {
|
||||
allowMultiple: true,
|
||||
expandable: ({ record }: { record: any }) => {
|
||||
if (record?.part_detail?.virtual) {
|
||||
return false;
|
||||
}
|
||||
return table.isRowExpanded(record.pk) || record.allocated > 0;
|
||||
},
|
||||
content: ({ record }: { record: any }) => {
|
||||
return (
|
||||
<TransferOrderAllocationTable
|
||||
showOrderInfo={false}
|
||||
showPartInfo={false}
|
||||
orderId={orderId}
|
||||
lineItemId={record.pk}
|
||||
partId={record.part}
|
||||
allowEdit={editable}
|
||||
modelTarget={ModelType.stockitem}
|
||||
modelField={'item'}
|
||||
isSubTable
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
}, [orderId, table.isRowExpanded]);
|
||||
|
||||
const tableFilters: TableFilter[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
name: 'allocated',
|
||||
label: t`Allocated`,
|
||||
description: t`Show lines which are fully allocated`
|
||||
},
|
||||
{
|
||||
name: 'completed',
|
||||
label: t`Completed`,
|
||||
description: t`Show lines which are completed`
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{editLine.modal}
|
||||
{deleteLine.modal}
|
||||
{newLine.modal}
|
||||
{newBuildOrder.modal}
|
||||
{allocateBySerials.modal}
|
||||
{allocateStock.modal}
|
||||
{orderPartsWizard.wizard}
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.transfer_order_line_list)}
|
||||
tableState={table}
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
enableSelection: true,
|
||||
enableDownload: true,
|
||||
params: {
|
||||
order: orderId,
|
||||
part_detail: true
|
||||
},
|
||||
rowActions: rowActions,
|
||||
tableActions: tableActions,
|
||||
tableFilters: tableFilters,
|
||||
rowExpansion: rowExpansion
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import type { TableFilter } from '@lib/types/Filters';
|
||||
import type { TableColumn } from '@lib/types/Tables';
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
import { DescriptionColumn, ReferenceColumn } from '../ColumnRenderers';
|
||||
import {
|
||||
AssignedToMeFilter,
|
||||
OrderStatusFilter,
|
||||
OutstandingFilter,
|
||||
OverdueFilter,
|
||||
ProjectCodeFilter,
|
||||
ResponsibleFilter
|
||||
} from '../Filter';
|
||||
import ParametricDataTable from '../general/ParametricDataTable';
|
||||
|
||||
export default function TransferOrderParametricTable({
|
||||
queryParams
|
||||
}: {
|
||||
queryParams?: Record<string, any>;
|
||||
}): ReactNode {
|
||||
const customColumns: TableColumn[] = useMemo(() => {
|
||||
return [ReferenceColumn({ switchable: false }), DescriptionColumn({})];
|
||||
}, []);
|
||||
|
||||
const customFilters: TableFilter[] = useMemo(() => {
|
||||
return [
|
||||
OrderStatusFilter({ model: ModelType.transferorder }),
|
||||
OutstandingFilter(),
|
||||
OverdueFilter(),
|
||||
AssignedToMeFilter(),
|
||||
ProjectCodeFilter(),
|
||||
ResponsibleFilter()
|
||||
];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ParametricDataTable
|
||||
modelType={ModelType.transferorder}
|
||||
endpoint={ApiEndpoints.transfer_order_list}
|
||||
customColumns={customColumns}
|
||||
customFilters={customFilters}
|
||||
queryParams={{
|
||||
...queryParams
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { AddItemButton, UserRoles, useTable } from '@lib/index';
|
||||
import type { TableFilter } from '@lib/types/Filters';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useMemo } from 'react';
|
||||
import { useTransferOrderFields } from '../../forms/TransferOrderForms';
|
||||
import { useCreateApiFormModal } from '../../hooks/UseForm';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import {
|
||||
BooleanColumn,
|
||||
CompletionDateColumn,
|
||||
CreatedByColumn,
|
||||
CreationDateColumn,
|
||||
DescriptionColumn,
|
||||
LocationColumn,
|
||||
ProjectCodeColumn,
|
||||
ReferenceColumn,
|
||||
ResponsibleColumn,
|
||||
StartDateColumn,
|
||||
StatusColumn,
|
||||
TargetDateColumn
|
||||
} from '../ColumnRenderers';
|
||||
import {
|
||||
AssignedToMeFilter,
|
||||
CompletedAfterFilter,
|
||||
CompletedBeforeFilter,
|
||||
CreatedAfterFilter,
|
||||
CreatedBeforeFilter,
|
||||
CreatedByFilter,
|
||||
HasProjectCodeFilter,
|
||||
IncludeVariantsFilter,
|
||||
MaxDateFilter,
|
||||
MinDateFilter,
|
||||
OrderStatusFilter,
|
||||
OutstandingFilter,
|
||||
OverdueFilter,
|
||||
ProjectCodeFilter,
|
||||
ResponsibleFilter,
|
||||
StartDateAfterFilter,
|
||||
StartDateBeforeFilter,
|
||||
TargetDateAfterFilter,
|
||||
TargetDateBeforeFilter
|
||||
} from '../Filter';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
|
||||
export function TransferOrderTable({
|
||||
partId
|
||||
}: Readonly<{
|
||||
partId?: number;
|
||||
}>) {
|
||||
const table = useTable(
|
||||
!!partId ? 'transferorders-part' : 'transferorders-index'
|
||||
);
|
||||
const user = useUserState();
|
||||
|
||||
const tableFilters: TableFilter[] = useMemo(() => {
|
||||
const filters: TableFilter[] = [
|
||||
OrderStatusFilter({ model: ModelType.transferorder }),
|
||||
OutstandingFilter(),
|
||||
OverdueFilter(),
|
||||
AssignedToMeFilter(),
|
||||
MinDateFilter(),
|
||||
MaxDateFilter(),
|
||||
CreatedBeforeFilter(),
|
||||
CreatedAfterFilter(),
|
||||
TargetDateBeforeFilter(),
|
||||
TargetDateAfterFilter(),
|
||||
StartDateBeforeFilter(),
|
||||
StartDateAfterFilter(),
|
||||
{
|
||||
name: 'has_target_date',
|
||||
type: 'boolean',
|
||||
label: t`Has Target Date`,
|
||||
description: t`Show orders with a target date`
|
||||
},
|
||||
{
|
||||
name: 'has_start_date',
|
||||
type: 'boolean',
|
||||
label: t`Has Start Date`,
|
||||
description: t`Show orders with a start date`
|
||||
},
|
||||
CompletedBeforeFilter(),
|
||||
CompletedAfterFilter(),
|
||||
HasProjectCodeFilter(),
|
||||
ProjectCodeFilter(),
|
||||
ResponsibleFilter(),
|
||||
CreatedByFilter()
|
||||
];
|
||||
|
||||
if (!!partId) {
|
||||
filters.push(IncludeVariantsFilter());
|
||||
}
|
||||
|
||||
return filters;
|
||||
}, [partId]);
|
||||
|
||||
const tableColumns = useMemo(() => {
|
||||
return [
|
||||
ReferenceColumn({}),
|
||||
DescriptionColumn({}),
|
||||
LocationColumn({
|
||||
accessor: 'take_from_detail',
|
||||
title: t`Source Location`
|
||||
}),
|
||||
LocationColumn({
|
||||
accessor: 'destination_detail',
|
||||
title: t`Destination Location`
|
||||
}),
|
||||
BooleanColumn({
|
||||
accessor: 'consume',
|
||||
title: t`Consume Stock`,
|
||||
sortable: true,
|
||||
switchable: true
|
||||
}),
|
||||
// LineItemsProgressColumn({}),
|
||||
StatusColumn({ model: ModelType.transferorder }),
|
||||
ProjectCodeColumn({
|
||||
defaultVisible: false
|
||||
}),
|
||||
CreationDateColumn({
|
||||
defaultVisible: false
|
||||
}),
|
||||
CreatedByColumn({
|
||||
defaultVisible: false
|
||||
}),
|
||||
StartDateColumn({
|
||||
defaultVisible: false
|
||||
}),
|
||||
TargetDateColumn({}),
|
||||
CompletionDateColumn({
|
||||
accessor: 'complete_date'
|
||||
}),
|
||||
ResponsibleColumn({})
|
||||
];
|
||||
}, []);
|
||||
|
||||
const transferOrderFields = useTransferOrderFields({});
|
||||
|
||||
const newTransferOrder = useCreateApiFormModal({
|
||||
url: ApiEndpoints.transfer_order_list,
|
||||
title: t`Add Transfer Order`,
|
||||
fields: transferOrderFields,
|
||||
initialData: {},
|
||||
follow: true,
|
||||
modelType: ModelType.transferorder
|
||||
});
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
return [
|
||||
<AddItemButton
|
||||
key='add-transfer-order'
|
||||
tooltip={t`Add Transfer Order`}
|
||||
onClick={() => newTransferOrder.open()}
|
||||
hidden={!user.hasAddRole(UserRoles.transfer_order)}
|
||||
/>
|
||||
];
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{newTransferOrder.modal}
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.transfer_order_list)}
|
||||
tableState={table}
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
params: {
|
||||
part: partId
|
||||
// customer: customerId,
|
||||
// customer_detail: true
|
||||
},
|
||||
tableFilters: tableFilters,
|
||||
tableActions: tableActions,
|
||||
modelType: ModelType.transferorder,
|
||||
enableSelection: true,
|
||||
enableDownload: true,
|
||||
enableReports: true,
|
||||
enableLabels: true
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { expect, test } from '../baseFixtures.js';
|
||||
import { stevenuser } from '../defaults.js';
|
||||
import {
|
||||
activateCalendarView,
|
||||
clearTableFilters,
|
||||
clickButtonIfVisible,
|
||||
clickOnRowMenu,
|
||||
loadTab,
|
||||
navigate,
|
||||
openFilterDrawer,
|
||||
@@ -548,3 +550,209 @@ test('Stock - Location', async ({ browser }) => {
|
||||
await page.getByRole('button', { name: 'Scan', exact: true }).click();
|
||||
await page.getByText('No match found for barcode data').waitFor();
|
||||
});
|
||||
|
||||
test('Transfer Orders - General', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await page.getByRole('tab', { name: 'Stock' }).click();
|
||||
await page.waitForURL('**/stock/location/index/**');
|
||||
|
||||
await loadTab(page, 'Transfer Orders');
|
||||
|
||||
await clearTableFilters(page);
|
||||
|
||||
// We have now loaded the "Transfer Orders" table. Check for some expected texts
|
||||
await page.getByText('Complete').first().waitFor();
|
||||
await page.getByText('Issued').first().waitFor();
|
||||
await page.getByText('Cancelled').first().waitFor();
|
||||
|
||||
// Load a particular Transfer Order
|
||||
await page.getByRole('cell', { name: 'TO-0002' }).click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// This transfer order should be "issued"
|
||||
await page.getByText('Issued').first().waitFor();
|
||||
|
||||
// Edit the transfer order (via keyboard shortcut)
|
||||
await page.keyboard.press('Control+E');
|
||||
await page.getByLabel('text-field-reference', { exact: true }).waitFor();
|
||||
await page.getByLabel('related-field-project_code').waitFor();
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete Order' }).click();
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
// Check for other expected actions
|
||||
await page.getByRole('button', { name: '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-hold').waitFor();
|
||||
|
||||
// Click on some tabs
|
||||
await loadTab(page, 'Line Items');
|
||||
await loadTab(page, 'Allocated Stock');
|
||||
await loadTab(page, 'Parameters');
|
||||
await loadTab(page, 'Attachments');
|
||||
await loadTab(page, 'Notes');
|
||||
});
|
||||
|
||||
test('Transfer Order - Reference', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
// go to transfer orders
|
||||
await page.getByRole('tab', { name: 'Stock' }).click();
|
||||
await page.waitForURL('**/stock/location/index/**');
|
||||
await loadTab(page, 'Transfer Orders');
|
||||
|
||||
// click add button
|
||||
await page
|
||||
.getByRole('button', { name: 'action-button-add-transfer-' })
|
||||
.click();
|
||||
|
||||
// Ensure a new reference is suggested
|
||||
await expect(
|
||||
page.getByLabel('text-field-reference', { exact: true })
|
||||
).not.toBeEmpty();
|
||||
// Grab the Transfer Order reference
|
||||
const reference: string = await page
|
||||
.getByRole('textbox', { name: 'text-field-reference' })
|
||||
.inputValue();
|
||||
expect(reference).toMatch(/TO-\d+/);
|
||||
|
||||
await page.getByRole('textbox', { name: 'text-field-description' }).click();
|
||||
await page
|
||||
.getByRole('textbox', { name: 'text-field-description' })
|
||||
.fill('creating from playwrigh!');
|
||||
|
||||
// create the transfer order
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Item Created').waitFor();
|
||||
|
||||
// go back to stock page
|
||||
await page.getByRole('link', { name: 'Stock', exact: true }).click();
|
||||
await page
|
||||
.getByRole('button', { name: 'action-button-add-transfer-' })
|
||||
.click();
|
||||
|
||||
const nextReference: string = await page
|
||||
.getByRole('textbox', { name: 'text-field-reference' })
|
||||
.inputValue();
|
||||
expect(nextReference).toMatch(/TO-\d+/);
|
||||
|
||||
// Ensure that the reference has incremented
|
||||
const refNumber = Number(reference.replace('TO-', ''));
|
||||
const nextRefNumber = Number(nextReference.replace('TO-', ''));
|
||||
expect(nextRefNumber).toBe(refNumber + 1);
|
||||
});
|
||||
|
||||
test('Transfer Order - Calendar', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await navigate(page, 'stock/location/index/transfer-orders');
|
||||
await activateCalendarView(page);
|
||||
|
||||
// Export calendar data
|
||||
await page.getByLabel('calendar-export-data').click();
|
||||
await page.getByRole('button', { name: 'Export', exact: true }).click();
|
||||
await page.getByText('Process completed successfully').waitFor();
|
||||
|
||||
// Required because we downloaded a file
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('Transfer Order - Edit', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await navigate(page, 'stock/transfer-order/2/');
|
||||
|
||||
// Check for expected text items
|
||||
await page.getByText('Consume some paint').first().waitFor();
|
||||
await page.getByText('2026-04-20').waitFor(); // Created date
|
||||
await page.getByText('2026-04-23').waitFor(); // Issue date
|
||||
await page.getByText('PRJ-HEL').waitFor(); // Project Code
|
||||
|
||||
await page.keyboard.press('Control+E');
|
||||
|
||||
// Edit start date
|
||||
await page.getByLabel('date-field-start_date').fill('2026-04-28');
|
||||
|
||||
// Submit the form
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Expect error
|
||||
await page.getByText('Errors exist for one or more form fields').waitFor();
|
||||
await page.getByText('Target date must be after start date').waitFor();
|
||||
|
||||
// Cancel the form
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
});
|
||||
|
||||
test('Transfer Order - Allocate and Transfer', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await navigate(page, 'stock/transfer-order/6/');
|
||||
|
||||
// Duplicate this transfer order, to ensure a fresh run each time
|
||||
await page.getByLabel('action-menu-order-actions').click();
|
||||
await page.getByLabel('action-menu-order-actions-duplicate').click();
|
||||
|
||||
// Submit the duplicate request and ensure it completes
|
||||
await page.getByRole('button', { name: 'Submit' }).isEnabled();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Item Created').waitFor();
|
||||
|
||||
// Issue the order
|
||||
await page.getByRole('button', { name: 'Issue Order' }).click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Order issued').waitFor();
|
||||
|
||||
await loadTab(page, 'Line Items');
|
||||
|
||||
// Allocate line item 1
|
||||
const cell1 = await page.getByText('C_100pF_0402', { exact: true });
|
||||
await clickOnRowMenu(cell1);
|
||||
await page.getByRole('menuitem', { name: 'Allocate Stock' }).click();
|
||||
await page.getByText('C_100pF_0402Location:Offsite').waitFor();
|
||||
await page.waitForTimeout(200);
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Allocate line item 1
|
||||
const cell2 = await page.getByText('R_2.2K_0603_1%', { exact: true });
|
||||
await clickOnRowMenu(cell2);
|
||||
await page.getByRole('menuitem', { name: 'Allocate Stock' }).click();
|
||||
await page.getByText('R_2.2K_0603_1%Location:').waitFor();
|
||||
await page.waitForTimeout(200);
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Complete the order
|
||||
await page.getByRole('button', { name: 'Complete Order' }).click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Complete', { exact: true }).first().waitFor();
|
||||
|
||||
// Tab should have changed to Transferred Stock
|
||||
await loadTab(page, 'Transferred Stock');
|
||||
await page.getByText('C_100pF_0402').waitFor();
|
||||
await page.getByText('2.2K resistor in 0603 SMD').waitFor();
|
||||
});
|
||||
|
||||
test('Transfer Orders - Duplicate', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, {
|
||||
url: 'stock/transfer-order/1/detail'
|
||||
});
|
||||
|
||||
await page.getByLabel('action-menu-order-actions').click();
|
||||
await page.getByLabel('action-menu-order-actions-duplicate').click();
|
||||
|
||||
// Ensure a new reference is suggested
|
||||
await expect(
|
||||
page.getByLabel('text-field-reference', { exact: true })
|
||||
).not.toBeEmpty();
|
||||
|
||||
// Submit the duplicate request and ensure it completes
|
||||
await page.getByRole('button', { name: 'Submit' }).isEnabled();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByRole('tab', { name: 'Order Details' }).waitFor();
|
||||
await page.getByRole('tab', { name: 'Order Details' }).click();
|
||||
|
||||
await page.getByText('Pending').first().waitFor();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user