feat(order): add RepairOrder order type (backend)

Introduce a customer-facing RepairOrder, closely mirroring the existing
ReturnOrder: models, serializers, DRF API endpoints, permissions, status
codes, system settings, admin and API tests. Backend only; stock
allocation/consumption is intentionally left for a follow-up.

Refs #12064
This commit is contained in:
Amey Pawar 2026-07-05 17:53:38 +05:30
parent 5df2adae6d
commit edf45e75b6
15 changed files with 1412 additions and 2 deletions

View File

@ -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,

View File

@ -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

View File

@ -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'),

View File

@ -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."""

View File

@ -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(
'<int:pk>/',
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(
'<int:pk>/',
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(
'<int:pk>/',
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/(?P<ordertype>purchase-order|sales-order|return-order|transfer-order)/calendar.ics',
r'^calendar/(?P<ordertype>purchase-order|sales-order|return-order|repair-order|transfer-order)/calendar.ics',
OrderCalendarExport(),
name='api-po-so-calendar',
),

View File

@ -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."""

View File

@ -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

View File

@ -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),
),
]

View File

@ -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.

View File

@ -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,

View File

@ -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."""

View File

@ -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."""

View File

@ -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

View File

@ -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'}

View File

@ -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',