diff --git a/src/backend/InvenTree/InvenTree/api.py b/src/backend/InvenTree/InvenTree/api.py index 1dfc53fa5c..dd9ce2f6e8 100644 --- a/src/backend/InvenTree/InvenTree/api.py +++ b/src/backend/InvenTree/InvenTree/api.py @@ -757,6 +757,7 @@ class APISearchView(GenericAPIView): 'partcategory': part.api.CategoryList, 'purchaseorder': order.api.PurchaseOrderList, 'returnorder': order.api.ReturnOrderList, + 'repairorder': order.api.RepairOrderList, 'salesorder': order.api.SalesOrderList, 'salesordershipment': order.api.SalesOrderShipmentList, 'stockitem': stock.api.StockList, diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 952bd5fa63..86a01e4717 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,15 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 515 +INVENTREE_API_VERSION = 516 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v516 -> 2026-07-05 : https://github.com/inventree/InvenTree/pull/XXXXX + - Adds RepairOrder model and API endpoints + - Adds RepairOrderStatus status codes + v515 -> 2026-07-03 : https://github.com/inventree/InvenTree/pull/12298 - Change the file fields definition to binary (from uri) in the upload requests diff --git a/src/backend/InvenTree/common/setting/system.py b/src/backend/InvenTree/common/setting/system.py index f950129187..b06d1e99a5 100644 --- a/src/backend/InvenTree/common/setting/system.py +++ b/src/backend/InvenTree/common/setting/system.py @@ -889,6 +889,28 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'default': False, 'validator': bool, }, + 'REPAIRORDER_REFERENCE_PATTERN': { + 'name': _('Repair Order Reference Pattern'), + 'description': _( + 'Required pattern for generating Repair Order reference field' + ), + 'default': 'RP-{ref:04d}', + 'validator': order.validators.validate_repair_order_reference_pattern, + }, + 'REPAIRORDER_REQUIRE_RESPONSIBLE': { + 'name': _('Require Responsible Owner'), + 'description': _('A responsible owner must be assigned to each order'), + 'default': False, + 'validator': bool, + }, + 'REPAIRORDER_EDIT_COMPLETED_ORDERS': { + 'name': _('Edit Completed Repair Orders'), + 'description': _( + 'Allow editing of repair orders after they have been completed' + ), + 'default': False, + 'validator': bool, + }, 'SALESORDER_REFERENCE_PATTERN': { 'name': _('Sales Order Reference Pattern'), 'description': _('Required pattern for generating Sales Order reference field'), diff --git a/src/backend/InvenTree/order/admin.py b/src/backend/InvenTree/order/admin.py index 875ebdb763..22302c29ad 100644 --- a/src/backend/InvenTree/order/admin.py +++ b/src/backend/InvenTree/order/admin.py @@ -172,6 +172,33 @@ class ReturnOrdeerExtraLineAdmin(GeneralExtraLineAdmin, admin.ModelAdmin): """Admin class for the ReturnOrderExtraLine model.""" +@admin.register(models.RepairOrder) +class RepairOrderAdmin(admin.ModelAdmin): + """Admin class for the RepairOrder model.""" + + exclude = ['reference_int'] + + list_display = ['reference', 'customer', 'status'] + + search_fields = ['reference', 'customer__name', 'description'] + + autocomplete_fields = ['customer', 'project_code', 'contact', 'address'] + + +@admin.register(models.RepairOrderLineItem) +class RepairOrderLineItemAdmin(admin.ModelAdmin): + """Admin class for RepairOrderLine model.""" + + list_display = ['order', 'item', 'reference'] + + autocomplete_fields = ['item', 'order'] + + +@admin.register(models.RepairOrderExtraLine) +class RepairOrderExtraLineAdmin(GeneralExtraLineAdmin, admin.ModelAdmin): + """Admin class for the RepairOrderExtraLine model.""" + + class TransferOrderLineItemInlineAdmin(admin.StackedInline): """Inline admin class for the TransferOrderLineItem model.""" diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index 40882dd38d..2c08bdc693 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -54,6 +54,7 @@ from order import models, serializers from order.status_codes import ( PurchaseOrderStatus, PurchaseOrderStatusGroups, + RepairOrderStatus, ReturnOrderLineStatus, ReturnOrderStatus, SalesOrderStatus, @@ -1824,6 +1825,274 @@ class ReturnOrderExtraLineDetail(RetrieveUpdateDestroyAPI): serializer_class = serializers.ReturnOrderExtraLineSerializer +class RepairOrderFilter(OrderFilter): + """Custom API filters for the RepairOrderList endpoint.""" + + class Meta: + """Metaclass options.""" + + model = models.RepairOrder + fields = ['customer'] + + 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 repair orders + line_items = models.RepairOrderLineItem.objects.filter(item__part__in=parts) + + # Generate a list of ID values for the matching repair orders + repair_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=repair_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 RepairOrderMixin(SerializerContextMixin): + """Mixin class for RepairOrder endpoints.""" + + queryset = models.RepairOrder.objects.all() + serializer_class = serializers.RepairOrderSerializer + + def get_queryset(self, *args, **kwargs): + """Return annotated queryset for this endpoint.""" + queryset = super().get_queryset(*args, **kwargs) + queryset = serializers.RepairOrderSerializer.annotate_queryset(queryset) + queryset = queryset.prefetch_related( + 'contact', 'created_by', 'customer', 'responsible' + ) + + return queryset + + +class RepairOrderOutputOptions(OutputConfiguration): + """Output options for the RepairOrder endpoint.""" + + OPTIONS = [InvenTreeOutputOption(flag='customer_detail')] + + +class RepairOrderList( + RepairOrderMixin, + OrderCreateMixin, + DataExportViewMixin, + OutputOptionsMixin, + ParameterListMixin, + ListCreateAPI, +): + """API endpoint for accessing a list of RepairOrder objects.""" + + filterset_class = RepairOrderFilter + filter_backends = SEARCH_ORDER_FILTER + + output_options = RepairOrderOutputOptions + + ordering_field_aliases = { + 'reference': ['reference_int', 'reference'], + 'project_code': ['project_code__code'], + } + + ordering_fields = [ + 'creation_date', + 'created_by', + 'reference', + 'customer__name', + 'customer_reference', + 'line_items', + 'status', + 'start_date', + 'target_date', + 'complete_date', + 'project_code', + 'updated_at', + ] + + search_fields = [ + 'customer__name', + 'reference', + 'description', + 'customer_reference', + 'project_code__code', + ] + + ordering = '-reference' + + +class RepairOrderDetail(RepairOrderMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI): + """API endpoint for detail view of a single RepairOrder object.""" + + output_options = RepairOrderOutputOptions + + +class RepairOrderContextMixin: + """Simple mixin class to add a RepairOrder to the serializer context.""" + + queryset = models.RepairOrder.objects.all() + + def get_serializer_context(self): + """Add the RepairOrder object to the serializer context.""" + context = super().get_serializer_context() + + # Pass the RepairOrder instance through to the serializer for validation + try: + context['order'] = models.RepairOrder.objects.get( + pk=self.kwargs.get('pk', None) + ) + except Exception: + pass + + context['request'] = self.request + + return context + + +class RepairOrderCancel(RepairOrderContextMixin, CreateAPI): + """API endpoint to cancel a RepairOrder.""" + + serializer_class = serializers.RepairOrderCancelSerializer + + +class RepairOrderHold(RepairOrderContextMixin, CreateAPI): + """API endpoint to hold a RepairOrder.""" + + serializer_class = serializers.RepairOrderHoldSerializer + + +class RepairOrderComplete(RepairOrderContextMixin, CreateAPI): + """API endpoint to complete a RepairOrder.""" + + serializer_class = serializers.RepairOrderCompleteSerializer + + +class RepairOrderIssue(RepairOrderContextMixin, CreateAPI): + """API endpoint to issue (place) a RepairOrder.""" + + serializer_class = serializers.RepairOrderIssueSerializer + + +class RepairOrderLineItemFilter(LineItemFilter): + """Custom filters for the RepairOrderLineItemList endpoint.""" + + class Meta: + """Metaclass options.""" + + price_field = 'price' + model = models.RepairOrderLineItem + fields = ['order', 'item'] + + outcome = rest_filters.NumberFilter(label='outcome') + + +class RepairOrderLineItemMixin(SerializerContextMixin): + """Mixin class for RepairOrderLineItem endpoints.""" + + queryset = models.RepairOrderLineItem.objects.all() + serializer_class = serializers.RepairOrderLineItemSerializer + + def get_queryset(self, *args, **kwargs): + """Return annotated queryset for this endpoint.""" + queryset = super().get_queryset(*args, **kwargs) + + queryset = queryset.prefetch_related('order', 'item', 'item__part') + + return queryset + + +class RepairOrderLineItemOutputOptions(OutputConfiguration): + """Output options for the RepairOrderLineItem endpoint.""" + + OPTIONS = [ + InvenTreeOutputOption('part_detail'), + InvenTreeOutputOption('item_detail', default=True), + InvenTreeOutputOption('order_detail'), + ] + + +class RepairOrderLineItemList( + RepairOrderLineItemMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateAPI +): + """API endpoint for accessing a list of RepairOrderLineItem objects.""" + + filterset_class = RepairOrderLineItemFilter + + filter_backends = SEARCH_ORDER_FILTER + + output_options = RepairOrderLineItemOutputOptions + + ordering_fields = ['part', 'IPN', 'stock', 'reference', 'target_date', 'line'] + + ordering_field_aliases = { + 'line': ['line_int', 'line', 'item__part__name'], + 'part': 'item__part__name', + 'IPN': 'item__part__IPN', + 'stock': ['item__quantity', 'item__serial_int', 'item__serial'], + } + + search_fields = [ + 'item__serial', + 'item__part__name', + 'item__part__description', + 'reference', + ] + + +class RepairOrderLineItemDetail( + RepairOrderLineItemMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI +): + """API endpoint for detail view of a RepairOrderLineItem object.""" + + output_options = RepairOrderLineItemOutputOptions + + +class RepairOrderExtraLineList(GeneralExtraLineList, OutputOptionsMixin, ListCreateAPI): + """API endpoint for accessing a list of RepairOrderExtraLine objects.""" + + queryset = models.RepairOrderExtraLine.objects.all() + serializer_class = serializers.RepairOrderExtraLineSerializer + + +class RepairOrderExtraLineDetail(RetrieveUpdateDestroyAPI): + """API endpoint for detail view of a RepairOrderExtraLine object.""" + + queryset = models.RepairOrderExtraLine.objects.all() + serializer_class = serializers.RepairOrderExtraLineSerializer + + class TransferOrderFilter(OrderFilter): """Custom API filters for the TransferOrderList endpoint.""" @@ -2417,6 +2686,8 @@ class OrderCalendarExport(ICalFeed): ordertype_title = _('Sales Order') elif obj['ordertype'] == 'return-order': ordertype_title = _('Return Order') + elif obj['ordertype'] == 'repair-order': + ordertype_title = _('Repair Order') elif obj['ordertype'] == 'transfer-order': ordertype_title = _('Transfer Order') else: @@ -2464,6 +2735,15 @@ class OrderCalendarExport(ICalFeed): ).filter(status__lt=ReturnOrderStatus.COMPLETE.value) else: outlist = models.ReturnOrder.objects.filter(target_date__isnull=False) + elif obj['ordertype'] == 'repair-order': + if obj['include_completed'] is False: + # Do not include completed orders from list in this case + # Complete status = 30 + outlist = models.RepairOrder.objects.filter( + target_date__isnull=False + ).filter(status__lt=RepairOrderStatus.COMPLETE.value) + else: + outlist = models.RepairOrder.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 @@ -2810,6 +3090,91 @@ order_api_urls = [ ), ]), ), + # API endpoints for repair orders + path( + 'repair-order/', + include([ + # Repair Order detail endpoints + path( + '/', + include([ + path( + 'cancel/', + RepairOrderCancel.as_view(), + name='api-repair-order-cancel', + ), + path( + 'hold/', RepairOrderHold.as_view(), name='api-repair-order-hold' + ), + path( + 'complete/', + RepairOrderComplete.as_view(), + name='api-repair-order-complete', + ), + path( + 'issue/', + RepairOrderIssue.as_view(), + name='api-repair-order-issue', + ), + meta_path(models.RepairOrder), + path( + '', RepairOrderDetail.as_view(), name='api-repair-order-detail' + ), + ]), + ), + # Repair order status code information + path( + 'status/', + StatusView.as_view(), + {StatusView.MODEL_REF: RepairOrderStatus}, + name='api-repair-order-status-codes', + ), + # Repair Order list + path('', RepairOrderList.as_view(), name='api-repair-order-list'), + ]), + ), + # API endpoints for repair order lines + path( + 'repair-order-line/', + include([ + path( + '/', + include([ + meta_path(models.RepairOrderLineItem), + path( + '', + RepairOrderLineItemDetail.as_view(), + name='api-repair-order-line-detail', + ), + ]), + ), + path( + '', RepairOrderLineItemList.as_view(), name='api-repair-order-line-list' + ), + ]), + ), + # API endpoints for repair order extra line + path( + 'repair-order-extra-line/', + include([ + path( + '/', + include([ + meta_path(models.RepairOrderExtraLine), + path( + '', + RepairOrderExtraLineDetail.as_view(), + name='api-repair-order-extra-line-detail', + ), + ]), + ), + path( + '', + RepairOrderExtraLineList.as_view(), + name='api-repair-order-extra-line-list', + ), + ]), + ), # API endpoints for transfer orders path( 'transfer-order/', @@ -2900,7 +3265,7 @@ order_api_urls = [ ), # API endpoint for subscribing to ICS calendar of purchase/sales/return orders re_path( - r'^calendar/(?Ppurchase-order|sales-order|return-order|transfer-order)/calendar.ics', + r'^calendar/(?Ppurchase-order|sales-order|return-order|repair-order|transfer-order)/calendar.ics', OrderCalendarExport(), name='api-po-so-calendar', ), diff --git a/src/backend/InvenTree/order/events.py b/src/backend/InvenTree/order/events.py index 093d6691aa..dcc2f48149 100644 --- a/src/backend/InvenTree/order/events.py +++ b/src/backend/InvenTree/order/events.py @@ -39,6 +39,15 @@ class ReturnOrderEvents(BaseEventEnum): HOLD = 'returnorder.hold' +class RepairOrderEvents(BaseEventEnum): + """Event enumeration for the Repair models.""" + + ISSUED = 'repairorder.issued' + COMPLETED = 'repairorder.completed' + CANCELLED = 'repairorder.cancelled' + HOLD = 'repairorder.hold' + + class TransferOrderEvents(BaseEventEnum): """Event enumeration for the PurchaseOrder models.""" diff --git a/src/backend/InvenTree/order/fixtures/repair_order.yaml b/src/backend/InvenTree/order/fixtures/repair_order.yaml new file mode 100644 index 0000000000..09853a693e --- /dev/null +++ b/src/backend/InvenTree/order/fixtures/repair_order.yaml @@ -0,0 +1,69 @@ +- model: order.repairorder + pk: 1 + fields: + reference: 'RP-001' + reference_int: 1 + description: 'Repair for a customer' + customer: 4 + status: 10 # Pending + +- model: order.repairorder + pk: 2 + fields: + reference: 'RP-002' + reference_int: 2 + description: 'Repair for a customer' + customer: 4 + status: 20 # In Progress + +- model: order.repairorder + pk: 3 + fields: + reference: 'RP-003' + reference_int: 3 + description: 'Repair for a customer' + customer: 4 + status: 30 # Complete + +- model: order.repairorder + pk: 4 + fields: + reference: 'RP-004' + reference_int: 4 + description: 'Repair for a customer' + customer: 5 + status: 40 # Cancelled + +- model: order.repairorder + pk: 5 + fields: + reference: 'RP-005' + reference_int: 5 + description: 'Repair for a customer' + customer: 5 + status: 20 # In progress + +- model: order.repairorder + pk: 6 + fields: + reference: 'RP-006' + reference_int: 6 + description: 'Repair for a customer' + customer: 5 + status: 10 # Pending + +# 1 x R_4K7_0603 +- model: order.repairorderlineitem + pk: 1 + fields: + order: 6 + item: 1008 + quantity: 1 + +# An extra line item +- model: order.repairorderextraline + pk: 1 + fields: + order: 6 + reference: 'Freight cost' + quantity: 1 diff --git a/src/backend/InvenTree/order/migrations/0121_repairorder.py b/src/backend/InvenTree/order/migrations/0121_repairorder.py new file mode 100644 index 0000000000..7391a7f456 --- /dev/null +++ b/src/backend/InvenTree/order/migrations/0121_repairorder.py @@ -0,0 +1,121 @@ +# Generated by Django 5.2.15 on 2026-07-05 07:34 + +import InvenTree.fields +import InvenTree.models +import InvenTree.validators +import django.core.validators +import django.db.models.deletion +import djmoney.models.fields +import djmoney.models.validators +import generic.states.fields +import generic.states.states +import generic.states.transition +import generic.states.validators +import order.status_codes +import order.validators +import taggit.managers +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0046_alter_emailmessage_global_id_and_more'), + ('company', '0080_company_tags'), + ('order', '0120_purchaseorder_tags_returnorder_tags_salesorder_tags_and_more'), + ('stock', '0123_remove_stockitem_review_needed'), + ('taggit', '0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx'), + ('users', '0015_alter_userprofile_type'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='RepairOrder', + 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')), + ('total_price_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True)), + ('total_price', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Total price for this order', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Total Price')), + ('order_currency', models.CharField(blank=True, help_text='Currency for this order (leave blank to use company default)', max_length=3, null=True, validators=[InvenTree.validators.validate_currency_code], verbose_name='Order Currency')), + ('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')), + ('updated_at', models.DateTimeField(blank=True, help_text='Timestamp of last update', null=True, verbose_name='Updated At')), + ('reference', models.CharField(default=order.validators.generate_next_repair_order_reference, help_text='Repair Order reference', max_length=64, unique=True, validators=[order.validators.validate_repair_order_reference], verbose_name='Reference')), + ('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.RepairOrderStatus)], verbose_name='Custom status key')), + ('status', generic.states.fields.InvenTreeCustomStatusModelField(choices=[(10, 'Pending'), (20, 'In Progress'), (25, 'On Hold'), (30, 'Complete'), (40, 'Cancelled')], default=10, help_text='Repair order status', validators=[generic.states.validators.CustomStatusCodeValidator(status_class=order.status_codes.RepairOrderStatus)], verbose_name='Status')), + ('customer_reference', models.CharField(blank=True, help_text='Customer order reference code', max_length=64, verbose_name='Customer Reference ')), + ('complete_date', models.DateField(blank=True, help_text='Date order was completed', null=True, verbose_name='Completion Date')), + ('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')), + ('customer', models.ForeignKey(help_text='Company for which items are being repaired', limit_choices_to={'is_customer': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='repair_orders', to='company.company', verbose_name='Customer')), + ('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')), + ('tags', taggit.managers.TaggableManager(blank=True, help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags')), + ], + options={ + 'verbose_name': 'Repair 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='RepairOrderExtraLine', + 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')), + ('line', models.CharField(blank=True, default='', help_text='Line number for this item (optional)', max_length=20, verbose_name='Line Number')), + ('line_int', models.IntegerField(default=0)), + ('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')), + ('description', models.CharField(blank=True, help_text='Line item description (optional)', max_length=250, verbose_name='Description')), + ('context', models.JSONField(blank=True, help_text='Additional context for this line', null=True, verbose_name='Context')), + ('price_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True)), + ('price', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Unit price', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Price')), + ('order', models.ForeignKey(help_text='Repair Order', on_delete=django.db.models.deletion.CASCADE, related_name='extra_lines', to='order.repairorder', verbose_name='Order')), + ('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')), + ], + options={ + 'verbose_name': 'Repair Order Extra Line', + }, + bases=(InvenTree.models.ContentTypeMixin, InvenTree.models.PluginValidationMixin, models.Model), + ), + migrations.CreateModel( + name='RepairOrderLineItem', + 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')), + ('line', models.CharField(blank=True, default='', help_text='Line number for this item (optional)', max_length=20, verbose_name='Line Number')), + ('line_int', models.IntegerField(default=0)), + ('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')), + ('quantity', models.DecimalField(decimal_places=5, default=1, help_text='Quantity to repair', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Quantity')), + ('outcome_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.ReturnOrderLineStatus)], verbose_name='Custom status key')), + ('outcome', generic.states.fields.InvenTreeCustomStatusModelField(choices=[(10, 'Pending'), (20, 'Return'), (30, 'Repair'), (40, 'Replace'), (50, 'Refund'), (60, 'Reject')], default=10, help_text='Outcome for this line item', validators=[generic.states.validators.CustomStatusCodeValidator(status_class=order.status_codes.ReturnOrderLineStatus)], verbose_name='Outcome')), + ('price_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True)), + ('price', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Cost associated with repair for this line item', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Price')), + ('item', models.ForeignKey(help_text='Select item to repair for customer', on_delete=django.db.models.deletion.CASCADE, related_name='repair_order_lines', to='stock.stockitem', verbose_name='Item')), + ('order', models.ForeignKey(help_text='Repair Order', on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='order.repairorder', verbose_name='Order')), + ('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')), + ], + options={ + 'verbose_name': 'Repair Order Line Item', + 'unique_together': {('order', 'item')}, + }, + bases=(generic.states.states.StatusCodeMixin, InvenTree.models.ContentTypeMixin, InvenTree.models.PluginValidationMixin, models.Model), + ), + ] diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index 6563bf1bc0..6c1a6a29e7 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -47,6 +47,7 @@ from InvenTree.helpers import decimal2string, pui_url from InvenTree.helpers_model import notify_responsible from order.events import ( PurchaseOrderEvents, + RepairOrderEvents, ReturnOrderEvents, SalesOrderEvents, TransferOrderEvents, @@ -54,6 +55,8 @@ from order.events import ( from order.status_codes import ( PurchaseOrderStatus, PurchaseOrderStatusGroups, + RepairOrderStatus, + RepairOrderStatusGroups, ReturnOrderLineStatus, ReturnOrderStatus, ReturnOrderStatusGroups, @@ -272,6 +275,28 @@ class ReturnOrderReportContext(report.mixins.BaseReportContext, TypedDict): customer: Optional[Company] +class RepairOrderReportContext(report.mixins.BaseReportContext, TypedDict): + """Context for the repair order model. + + Attributes: + description: The description field of the RepairOrder + reference: The reference field of the RepairOrder + title: The title (string representation) of the RepairOrder + extra_lines: Query set of all extra lines associated with the RepairOrder + lines: Query set of all line items associated with the RepairOrder + order: The RepairOrder instance itself + customer: The customer object associated with the RepairOrder + """ + + description: str + reference: str + title: str + extra_lines: report.mixins.QuerySet['RepairOrderExtraLine'] + lines: report.mixins.QuerySet['RepairOrderLineItem'] + order: 'RepairOrder' + customer: Optional[Company] + + class TransferOrderReportContext(BaseOrderReportContext, TypedDict): """Context for the transfer order model. @@ -3351,6 +3376,346 @@ class ReturnOrderExtraLine(OrderExtraLine): ) +class RepairOrder(TotalPriceMixin, Order): + """A RepairOrder represents a customer's item which is to be repaired. + + Attributes: + customer: Reference to the customer + status: The status of the order (refer to status_codes.RepairOrderStatus) + """ + + REFERENCE_PATTERN_SETTING = 'REPAIRORDER_REFERENCE_PATTERN' + REQUIRE_RESPONSIBLE_SETTING = 'REPAIRORDER_REQUIRE_RESPONSIBLE' + STATUS_CLASS = RepairOrderStatus + UNLOCK_SETTING = 'REPAIRORDER_EDIT_COMPLETED_ORDERS' + + class Meta: + """Model meta options.""" + + verbose_name = _('Repair Order') + + def report_context(self) -> RepairOrderReportContext: + """Generate report context data for this RepairOrder.""" + repair_ctx = super().report_context() + + repair_ctx.update({'customer': self.customer}) # ty:ignore[invalid-key] + return repair_ctx + + def get_absolute_url(self): + """Get the 'web' URL for this order.""" + return pui_url(f'/sales/repair-order/{self.pk}') + + @staticmethod + def get_api_url(): + """Return the API URL associated with the RepairOrder model.""" + return reverse('api-repair-order-list') + + @classmethod + def get_status_class(cls): + """Return the RepairOrderStatus class.""" + return RepairOrderStatusGroups + + @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_repair_order_reference() + } + + return defaults + + @classmethod + def barcode_model_type_code(cls): + """Return the associated barcode model type code for this model.""" + return 'RP' + + def subscribed_users(self) -> list[User]: + """Return a list of users subscribed to this RepairOrder. + + 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.item and line.item.part: + # Add the part to the list of subscribed users + for user in line.item.part.get_subscribers(): + subscribed_users.add(user) + + return list(subscribed_users) + + def __str__(self): + """Render a string representation of this RepairOrder.""" + return f'{self.reference} - {self.customer.name if self.customer else _("no customer")}' + + reference = models.CharField( + unique=True, + max_length=64, + blank=False, + verbose_name=_('Reference'), + help_text=_('Repair Order reference'), + default=order.validators.generate_next_repair_order_reference, + validators=[order.validators.validate_repair_order_reference], + ) + + customer = models.ForeignKey( + Company, + on_delete=models.SET_NULL, + null=True, + limit_choices_to={'is_customer': True}, + related_name='repair_orders', + verbose_name=_('Customer'), + help_text=_('Company for which items are being repaired'), + ) + + @property + def company(self): + """Accessor helper for Order base class.""" + return self.customer + + status = InvenTreeCustomStatusModelField( + default=RepairOrderStatus.PENDING.value, + choices=RepairOrderStatus.items(), + status_class=RepairOrderStatus, + verbose_name=_('Status'), + help_text=_('Repair order status'), + ) + + customer_reference = models.CharField( + max_length=64, + blank=True, + verbose_name=_('Customer Reference '), + help_text=_('Customer order reference code'), + ) + + complete_date = models.DateField( + blank=True, + null=True, + verbose_name=_('Completion Date'), + help_text=_('Date order was completed'), + ) + + # region state changes + @property + def is_pending(self): + """Return True if this order is pending.""" + return self.status == RepairOrderStatus.PENDING + + @property + def is_open(self): + """Return True if this order is outstanding.""" + return self.status in RepairOrderStatusGroups.OPEN + + @property + def can_hold(self): + """Return True if this order can be placed on hold.""" + return self.status in [ + RepairOrderStatus.PENDING.value, + RepairOrderStatus.IN_PROGRESS.value, + ] + + def _action_hold(self, *args, **kwargs): + """Mark this order as 'on hold' (if allowed).""" + if self.can_hold: + self.status = RepairOrderStatus.ON_HOLD.value + self.save() + + trigger_event(RepairOrderEvents.HOLD, id=self.pk) + + @property + def can_cancel(self): + """Return True if this order can be cancelled.""" + return self.status in RepairOrderStatusGroups.OPEN + + def _action_cancel(self, *args, **kwargs): + """Cancel this RepairOrder (if not already cancelled).""" + if self.can_cancel: + self.status = RepairOrderStatus.CANCELLED.value + self.save() + + trigger_event(RepairOrderEvents.CANCELLED, id=self.pk) + + # Notify users that the order has been canceled + notify_responsible( + self, + RepairOrder, + exclude=self.created_by, + content=InvenTreeNotificationBodies.OrderCanceled, + extra_users=self.subscribed_users(), + ) + + def _action_complete(self, *args, **kwargs): + """Complete this RepairOrder (if not already completed).""" + if self.status == RepairOrderStatus.IN_PROGRESS.value: + self.status = RepairOrderStatus.COMPLETE.value + self.complete_date = InvenTree.helpers.current_date() + self.save() + + trigger_event(RepairOrderEvents.COMPLETED, id=self.pk) + + def place_order(self): + """Deprecated version of 'issue_order.""" + self.issue_order() + + @property + def can_issue(self): + """Return True if this order can be issued.""" + return self.status in [ + RepairOrderStatus.PENDING.value, + RepairOrderStatus.ON_HOLD.value, + ] + + def _action_place(self, *args, **kwargs): + """Issue this RepairOrder (if currently pending).""" + if self.can_issue: + self.status = RepairOrderStatus.IN_PROGRESS.value + self.issue_date = InvenTree.helpers.current_date() + self.save() + + trigger_event(RepairOrderEvents.ISSUED, id=self.pk) + + # Notify users that the order has been placed + notify_responsible( + self, + RepairOrder, + exclude=self.created_by, + content=InvenTreeNotificationBodies.NewOrder, + extra_users=self.subscribed_users(), + ) + + @transaction.atomic + def hold_order(self): + """Attempt to transition to ON_HOLD status.""" + return self.handle_transition( + self.status, RepairOrderStatus.ON_HOLD.value, self, self._action_hold + ) + + @transaction.atomic + def issue_order(self): + """Attempt to transition to IN_PROGRESS status.""" + return self.handle_transition( + self.status, RepairOrderStatus.IN_PROGRESS.value, self, self._action_place + ) + + @transaction.atomic + def complete_order(self): + """Attempt to transition to COMPLETE status.""" + return self.handle_transition( + self.status, RepairOrderStatus.COMPLETE.value, self, self._action_complete + ) + + @transaction.atomic + def cancel_order(self): + """Attempt to transition to CANCELLED status.""" + return self.handle_transition( + self.status, RepairOrderStatus.CANCELLED.value, self, self._action_cancel + ) + + # endregion + + +class RepairOrderLineItem(StatusCodeMixin, OrderLineItem): + """Model for a single LineItem in a RepairOrder.""" + + STATUS_CLASS = ReturnOrderLineStatus + STATUS_FIELD = 'outcome' + + class Meta: + """Metaclass options for this model.""" + + verbose_name = _('Repair Order Line Item') + unique_together = [('order', 'item')] + + @staticmethod + def get_api_url(): + """Return the API URL associated with this model.""" + return reverse('api-repair-order-line-list') + + def clean(self): + """Perform extra validation steps for the RepairOrderLineItem model.""" + super().clean() + + if not self.item: + raise ValidationError({'item': _('Stock item must be specified')}) + + if self.quantity > self.item.quantity: + raise ValidationError({ + 'quantity': _('Repair quantity exceeds stock quantity') + }) + + if self.quantity <= 0: + raise ValidationError({ + 'quantity': _('Repair quantity must be greater than zero') + }) + + if self.item.serialized and self.quantity != 1: + raise ValidationError({ + 'quantity': _('Invalid quantity for serialized stock item') + }) + + order = models.ForeignKey( + RepairOrder, + on_delete=models.CASCADE, + related_name='lines', + verbose_name=_('Order'), + help_text=_('Repair Order'), + ) + + item = models.ForeignKey( + stock.models.StockItem, + on_delete=models.CASCADE, + related_name='repair_order_lines', + verbose_name=_('Item'), + help_text=_('Select item to repair for customer'), + ) + + quantity = models.DecimalField( + verbose_name=('Quantity'), + help_text=('Quantity to repair'), + max_digits=15, + decimal_places=5, + validators=[MinValueValidator(0)], + default=1, + ) + + outcome = InvenTreeCustomStatusModelField( + default=ReturnOrderLineStatus.PENDING.value, + choices=ReturnOrderLineStatus.items(), + status_class=ReturnOrderLineStatus, + verbose_name=_('Outcome'), + help_text=_('Outcome for this line item'), + ) + + price = InvenTreeModelMoneyField( + null=True, + blank=True, + verbose_name=_('Price'), + help_text=_('Cost associated with repair for this line item'), + ) + + +class RepairOrderExtraLine(OrderExtraLine): + """Model for a single ExtraLine in a RepairOrder.""" + + class Meta: + """Metaclass options for this model.""" + + verbose_name = _('Repair Order Extra Line') + + @staticmethod + def get_api_url(): + """Return the API URL associated with the RepairOrderExtraLine model.""" + return reverse('api-repair-order-extra-line-list') + + order = models.ForeignKey( + RepairOrder, + on_delete=models.CASCADE, + related_name='extra_lines', + verbose_name=_('Order'), + help_text=_('Repair Order'), + ) + + 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. diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index 04dc6e79f4..99b429a48d 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -2453,6 +2453,203 @@ class ReturnOrderExtraLineSerializer( ) +@register_importer() +class RepairOrderSerializer( + NotesFieldMixin, + InvenTreeCustomStatusSerializerMixin, + AbstractOrderSerializer, + TotalPriceMixin, + InvenTreeModelSerializer, +): + """Serializer for the RepairOrder model class.""" + + class Meta: + """Metaclass options.""" + + model = order.models.RepairOrder + fields = AbstractOrderSerializer.order_fields([ + 'complete_date', + 'customer', + 'customer_detail', + 'customer_reference', + 'order_currency', + 'total_price', + 'updated_at', + ]) + read_only_fields = ['creation_date', 'updated_at'] + + def skip_create_fields(self): + """Skip these fields when instantiating a new object.""" + fields = super().skip_create_fields() + + return [*fields, 'duplicate'] + + # Note: line items cannot be duplicated from a RepairOrder, + # as they are linked to specific stock items + duplicate = DuplicateOptionsSerializer( + order.models.RepairOrder.objects.all(), + copy_extra_lines=True, + copy_parameters=True, + ) + + @staticmethod + def annotate_queryset(queryset): + """Custom annotation for the serializer queryset.""" + queryset = AbstractOrderSerializer.annotate_queryset(queryset) + + queryset = queryset.annotate( + completed_lines=SubqueryCount( + 'lines', filter=~Q(outcome=ReturnOrderLineStatus.PENDING.value) + ) + ) + + queryset = queryset.annotate( + overdue=Case( + When( + order.models.RepairOrder.overdue_filter(), + then=Value(True, output_field=BooleanField()), + ), + default=Value(False, output_field=BooleanField()), + ) + ) + + return queryset + + customer_detail = OptionalField( + serializer_class=CompanyBriefSerializer, + serializer_kwargs={ + 'source': 'customer', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + prefetch_fields=['customer'], + ) + + +class RepairOrderHoldSerializer(OrderAdjustSerializer): + """Serializers for holding a RepairOrder.""" + + def save(self): + """Save the serializer to 'hold' the order.""" + self.order.hold_order() + + +class RepairOrderIssueSerializer(OrderAdjustSerializer): + """Serializer for issuing a RepairOrder.""" + + def save(self): + """Save the serializer to 'issue' the order.""" + self.order.issue_order() + + +class RepairOrderCancelSerializer(OrderAdjustSerializer): + """Serializer for cancelling a RepairOrder.""" + + def save(self): + """Save the serializer to 'cancel' the order.""" + self.order.cancel_order() + + +class RepairOrderCompleteSerializer(OrderAdjustSerializer): + """Serializer for completing a RepairOrder.""" + + def save(self): + """Save the serializer to 'complete' the order.""" + self.order.complete_order() + + +@register_importer() +class RepairOrderLineItemSerializer( + DataImportExportSerializerMixin, + AbstractLineItemSerializer, + InvenTreeModelSerializer, +): + """Serializer for a RepairOrderLineItem object.""" + + class Meta: + """Metaclass options.""" + + model = order.models.RepairOrderLineItem + fields = AbstractLineItemSerializer.line_fields([ + 'item', + 'outcome', + 'price', + 'price_currency', + # Filterable detail fields + 'item_detail', + 'part_detail', + ]) + + order_detail = OptionalField( + serializer_class=RepairOrderSerializer, + serializer_kwargs={ + 'source': 'order', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + prefetch_fields=[ + 'order__created_by', + 'order__responsible', + 'order__address', + 'order__project_code', + 'order__contact', + ], + ) + + quantity = serializers.FloatField( + label=_('Quantity'), help_text=_('Quantity to repair') + ) + + item_detail = OptionalField( + serializer_class=stock.serializers.StockItemSerializer, + serializer_kwargs={ + 'source': 'item', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + prefetch_fields=['item__supplier_part'], + ) + + part_detail = OptionalField( + serializer_class=PartBriefSerializer, + serializer_kwargs={ + 'source': 'item.part', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + ) + + price = InvenTreeMoneySerializer(allow_null=True) + price_currency = InvenTreeCurrencySerializer(help_text=_('Line price currency')) + + +@register_importer() +class RepairOrderExtraLineSerializer( + AbstractExtraLineSerializer, InvenTreeModelSerializer +): + """Serializer for a RepairOrderExtraLine object.""" + + class Meta(AbstractExtraLineMeta): + """Metaclass options.""" + + model = order.models.RepairOrderExtraLine + fields = AbstractExtraLineSerializer.extra_line_fields([]) + + order_detail = OptionalField( + serializer_class=RepairOrderSerializer, + serializer_kwargs={ + 'source': 'order', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + ) + + @register_importer() class TransferOrderSerializer( NotesFieldMixin, diff --git a/src/backend/InvenTree/order/status_codes.py b/src/backend/InvenTree/order/status_codes.py index 170a4a2a44..f3f58cc50f 100644 --- a/src/backend/InvenTree/order/status_codes.py +++ b/src/backend/InvenTree/order/status_codes.py @@ -118,6 +118,33 @@ class ReturnOrderLineStatus(StatusCode): REJECT = 60, _('Reject'), ColorEnum.danger +class RepairOrderStatus(StatusCode): + """Defines a set of status codes for a RepairOrder.""" + + # Order is pending, waiting for the item to be repaired + PENDING = 10, _('Pending'), ColorEnum.secondary + + # Item is currently being repaired + IN_PROGRESS = 20, _('In Progress'), ColorEnum.primary + + ON_HOLD = 25, _('On Hold'), ColorEnum.warning + + COMPLETE = 30, _('Complete'), ColorEnum.success + CANCELLED = 40, _('Cancelled'), ColorEnum.danger + + +class RepairOrderStatusGroups: + """Groups for RepairOrderStatus codes.""" + + OPEN = [ + RepairOrderStatus.PENDING.value, + RepairOrderStatus.ON_HOLD.value, + RepairOrderStatus.IN_PROGRESS.value, + ] + + COMPLETE = [RepairOrderStatus.COMPLETE.value] + + class TransferOrderStatus(StatusCode): """Defines a set of status codes for a TransferOrder.""" diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index e72bd91de5..57c6571c40 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -25,6 +25,7 @@ from order import models from order.models import SalesOrderAllocation, SalesOrderLineItem, SalesOrderShipment from order.status_codes import ( PurchaseOrderStatus, + RepairOrderStatus, ReturnOrderLineStatus, ReturnOrderStatus, SalesOrderStatus, @@ -3017,6 +3018,176 @@ class ReturnOrderTests(InvenTreeAPITestCase): ) +class RepairOrderTests(InvenTreeAPITestCase): + """Unit tests for RepairOrder API endpoints.""" + + fixtures = [ + 'category', + 'company', + 'repair_order', + 'part', + 'location', + 'supplier_part', + 'stock', + ] + roles = ['repair_order.view'] + + def test_options(self): + """Test the OPTIONS endpoint.""" + self.assignRole('repair_order.add') + data = self.options(reverse('api-repair-order-list'), expected_code=200).data + + self.assertEqual(data['name'], 'Repair Order List') + + # Some checks on the 'reference' field + post = data['actions']['POST'] + reference = post['reference'] + + self.assertEqual(reference['default'], 'RP-0007') + self.assertEqual(reference['label'], 'Reference') + self.assertEqual(reference['help_text'], 'Repair Order reference') + self.assertEqual(reference['required'], True) + self.assertEqual(reference['type'], 'string') + + def test_list(self): + """Tests for the list endpoint.""" + url = reverse('api-repair-order-list') + + response = self.get(url, expected_code=200) + + self.assertEqual(len(response.data), 6) + + # Paginated query + data = self.get( + url, + {'limit': 1, 'ordering': 'reference', 'customer_detail': True}, + expected_code=200, + ).data + + self.assertEqual(data['count'], 6) + self.assertEqual(len(data['results']), 1) + result = data['results'][0] + self.assertEqual(result['reference'], 'RP-001') + self.assertEqual(result['customer_detail']['name'], 'A customer') + + # Reverse ordering + data = self.get(url, {'ordering': '-reference'}, expected_code=200).data + + self.assertEqual(data[0]['reference'], 'RP-006') + + # Filter by customer + for cmp_id in [4, 5]: + data = self.get(url, {'customer': cmp_id}, expected_code=200).data + + self.assertEqual(len(data), 3) + + for result in data: + self.assertEqual(result['customer'], cmp_id) + + # Filter by status + data = self.get( + url, {'status': RepairOrderStatus.IN_PROGRESS.value}, expected_code=200 + ).data + + self.assertEqual(len(data), 2) + + for result in data: + self.assertEqual(result['status'], 20) + + def test_create(self): + """Test creation of RepairOrder via the API.""" + url = reverse('api-repair-order-list') + + # Do not have required permissions yet + self.post( + url, {'customer': 1, 'description': 'a repair order'}, expected_code=403 + ) + + self.assignRole('repair_order.add') + + data = self.post( + url, + { + 'customer': 4, + 'customer_reference': 'cr', + 'description': 'a repair order', + }, + expected_code=201, + ).data + + # Reference automatically generated + self.assertEqual(data['reference'], 'RP-0007') + self.assertEqual(data['customer_reference'], 'cr') + + def test_detail(self): + """Test that we can view and update a RepairOrder via the API.""" + url = reverse('api-repair-order-detail', kwargs={'pk': 1}) + + # Test detail endpoint + data = self.get(url, expected_code=200).data + + self.assertEqual(data['reference'], 'RP-001') + + # Attempt to update, incorrect permissions + self.patch( + url, {'customer_reference': 'My customer reference'}, expected_code=403 + ) + + self.assignRole('repair_order.change') + + self.patch(url, {'customer_reference': 'customer ref'}, expected_code=200) + + rp = models.RepairOrder.objects.get(pk=1) + self.assertEqual(rp.customer_reference, 'customer ref') + + def test_state_transitions(self): + """Test the state transition endpoints for a RepairOrder.""" + # Issue: PENDING -> IN_PROGRESS + order = models.RepairOrder.objects.get(pk=1) + self.assertEqual(order.status, RepairOrderStatus.PENDING) + self.assertIsNone(order.issue_date) + + url = reverse('api-repair-order-issue', kwargs={'pk': 1}) + + # POST without required permissions + self.post(url, expected_code=403) + + self.assignRole('repair_order.add') + + self.post(url, expected_code=201) + order.refresh_from_db() + self.assertEqual(order.status, RepairOrderStatus.IN_PROGRESS) + self.assertIsNotNone(order.issue_date) + + # Hold: IN_PROGRESS -> ON_HOLD + self.post(reverse('api-repair-order-hold', kwargs={'pk': 1}), expected_code=201) + order.refresh_from_db() + self.assertEqual(order.status, RepairOrderStatus.ON_HOLD) + + # Issue again: ON_HOLD -> IN_PROGRESS + self.post(url, expected_code=201) + order.refresh_from_db() + self.assertEqual(order.status, RepairOrderStatus.IN_PROGRESS) + + # Complete: IN_PROGRESS -> COMPLETE + self.post( + reverse('api-repair-order-complete', kwargs={'pk': 1}), expected_code=201 + ) + order.refresh_from_db() + self.assertEqual(order.status, RepairOrderStatus.COMPLETE) + self.assertIsNotNone(order.complete_date) + + # Cancel a different (still open) order + cancel_order = models.RepairOrder.objects.get(pk=6) + self.assertEqual(cancel_order.status, RepairOrderStatus.PENDING) + + self.post( + reverse('api-repair-order-cancel', kwargs={'pk': 6}), expected_code=201 + ) + cancel_order.refresh_from_db() + self.assertEqual(cancel_order.status, RepairOrderStatus.CANCELLED) + + class ReturnOrderLineItemTests(InvenTreeAPITestCase): """Unit tests for ReturnOrderLineItem API endpoints.""" diff --git a/src/backend/InvenTree/order/validators.py b/src/backend/InvenTree/order/validators.py index ce2004de76..4506bc068a 100644 --- a/src/backend/InvenTree/order/validators.py +++ b/src/backend/InvenTree/order/validators.py @@ -22,6 +22,13 @@ def generate_next_return_order_reference(): return ReturnOrder.generate_reference() +def generate_next_repair_order_reference(): + """Generate the next available RepairOrder reference.""" + from order.models import RepairOrder + + return RepairOrder.generate_reference() + + def generate_next_transfer_order_reference(): """Generate the next available TransferOrder reference.""" from order.models import TransferOrder @@ -50,6 +57,13 @@ def validate_return_order_reference_pattern(pattern): ReturnOrder.validate_reference_pattern(pattern) +def validate_repair_order_reference_pattern(pattern): + """Validate the RepairOrder reference 'pattern' setting.""" + from order.models import RepairOrder + + RepairOrder.validate_reference_pattern(pattern) + + def validate_sales_order_reference(value): """Validate that the SalesOrder reference field matches the required pattern.""" from order.models import SalesOrder @@ -71,6 +85,13 @@ def validate_return_order_reference(value): ReturnOrder.validate_reference_field(value) +def validate_repair_order_reference(value): + """Validate that the RepairOrder reference field matches the required pattern.""" + from order.models import RepairOrder + + RepairOrder.validate_reference_field(value) + + def validate_transfer_order_reference_pattern(pattern): """Validate the TransferOrder reference 'pattern' setting.""" from order.models import TransferOrder diff --git a/src/backend/InvenTree/users/oauth2_scopes.py b/src/backend/InvenTree/users/oauth2_scopes.py index 52656a33fa..5410fc5a42 100644 --- a/src/backend/InvenTree/users/oauth2_scopes.py +++ b/src/backend/InvenTree/users/oauth2_scopes.py @@ -20,6 +20,7 @@ _roles = { 'purchase_order': 'Role Purchase Orders', 'sales_order': 'Role Sales Orders', 'return_order': 'Role Return Orders', + 'repair_order': 'Role Repair Orders', 'transfer_order': 'Role Transfer Orders', } _methods = {'view': 'GET', 'add': 'POST', 'change': 'PUT / PATCH', 'delete': 'DELETE'} diff --git a/src/backend/InvenTree/users/ruleset.py b/src/backend/InvenTree/users/ruleset.py index 3787eb89b9..9348380164 100644 --- a/src/backend/InvenTree/users/ruleset.py +++ b/src/backend/InvenTree/users/ruleset.py @@ -19,6 +19,7 @@ class RuleSetEnum(StringEnum): PURCHASE_ORDER = 'purchase_order' SALES_ORDER = 'sales_order' RETURN_ORDER = 'return_order' + REPAIR_ORDER = 'repair_order' TRANSFER_ORDER = 'transfer_order' @@ -35,6 +36,7 @@ RULESET_CHOICES = [ (RuleSetEnum.PURCHASE_ORDER, _('Purchase Orders')), (RuleSetEnum.SALES_ORDER, _('Sales Orders')), (RuleSetEnum.RETURN_ORDER, _('Return Orders')), + (RuleSetEnum.REPAIR_ORDER, _('Repair Orders')), (RuleSetEnum.TRANSFER_ORDER, _('Transfer Orders')), ] @@ -163,6 +165,14 @@ def get_ruleset_models() -> dict: 'order_returnorderlineitem', 'order_returnorderextraline', ], + RuleSetEnum.REPAIR_ORDER: [ + 'company_company', + 'company_contact', + 'company_address', + 'order_repairorder', + 'order_repairorderlineitem', + 'order_repairorderextraline', + ], RuleSetEnum.TRANSFER_ORDER: [ 'order_transferorder', 'order_transferorderallocation',